Why does mutating a struct asserted out of a Go any value leave the boxed value unchanged?
answer
- the interface points at its own copy
- assertions produce values, not variables
- not addressable, so you cannot assign
- one word each way if you box a pointer
- big struct, two copies per message
basics
~20 sA type assertion yields a copy. Storing a struct in an interface copies it behind the interface's data word, and asserting it back out copies those bytes into your variable, so every write you make reaches only that copy.
solid answer
~40 sInterfaces hold values indirectly: boxing a struct into `any` copies the struct into memory the interface's data word points at, and `c := ev.(Event)` copies it back out into `c`. `c` and the boxed value are then two independent structs, so `c.Retries++` updates neither the interface nor anything else holding it. The language makes that explicit — `ev.(Event).Retries = 3` does not compile, because the result of an assertion is not addressable. If you want the mutation to stick, box a `*Event` instead and assert to `*Event`: then both the boxing and the unboxing move one pointer, and you are mutating the one struct everybody shares. That also matters for throughput, because a dispatcher that boxes and unboxes a large event copies the whole struct twice per message.
code
go · 9 linestype Event struct{ Retries int }
func bump(ev any) {
c := ev.(Event)
c.Retries++
fmt.Println(ev.(Event).Retries, c.Retries) // prints: 0 1 for Event{}
// ev.(Event).Retries++ // compile error: cannot assign, not addressable
}go deeper
Remember that Go structs are values: putting one in an interface and taking it back out gives you copies, so changing what you took out does not change what is stored.
Explain the mechanism both ways: boxing copies the struct into memory the interface points at, unboxing copies it back, and the assertion result is not addressable, which is why the direct assignment does not compile.
Bring the throughput angle: on a per-message dispatcher a large event is copied in and out on every hop, and switching to pointer boxing trades those copies for aliasing you must then govern.
Set the bus contract: whether events travel as values (snapshot per subscriber, safe under concurrency) or as pointers (cheap, shared, mutable). It is a one-way door for every handler team once published.
## Interfaces store values indirectly An interface value is a pair of words: one identifies the dynamic type, one is the data word. The data word is a pointer to the value, not the value itself. So putting a struct into an interface cannot simply move it — the struct has to live somewhere the interface can point at, and the compiler arranges a copy for that purpose. Putting a *pointer* into an interface is different: a pointer is already one word, so the data word can be the pointer itself and nothing is duplicated. That asymmetry is the whole answer to the mutation puzzle. ## The round trip ```go type Event struct{ Retries int } var ev any = Event{} // the struct is copied into memory ev points at c := ev.(Event) // the bytes are copied back out into c c.Retries++ // c is a third, independent struct value ``` After `c := ev.(Event)`, `c` and the value inside `ev` are separate structs. Incrementing `c.Retries` updates `c` only. Assert again and you get another fresh copy showing the original value. This is not a special interface rule — it is Go's ordinary value semantics, applied at the moment the value crosses the interface boundary in each direction. The language refuses to let you pretend otherwise. `ev.(Event).Retries = 3` is a compile error: the result of a type assertion is a value, not a variable, so it is not addressable and cannot be assigned to. The same applies to calling a pointer-receiver method on it — there is no address for the compiler to take. If it did compile, it would write into a temporary that is discarded on the next line, so refusing is the right call. ## A type switch copies too The binding form is no different: ```go switch e := ev.(type) { case Event: e.Retries++ // e is a copy; the boxed Event is untouched case *Event: e.Retries++ // e is the pointer; this write is visible to every holder } ``` In the `Event` arm, `e` is a copy created by the same unboxing. In the `*Event` arm, `e` is a pointer copied out — a single word — and dereferencing it reaches the one struct everybody shares. Reviewers who see `case Event:` followed by field writes should ask whether those writes were meant to be visible. ## The cost side Because the copy is of the whole value, size matters. A dispatcher that receives heterogeneous events as `any` and asserts each one back to its concrete type pays, per message, one copy into the interface and one copy out of it. For a 24-byte event that is noise. For a struct with a dozen fields, a time value and a couple of slices — several hundred bytes — copied twice per message on a path that handles tens of thousands of messages a second, it is real, and it is invisible in the source: the assertion looks like a test, not like a memcpy. Boxing pointers changes both sides to one word each. The tradeoff is the usual one: the value inside the interface is now shared and mutable, so if a handler stores it, mutates it, or hands it to another goroutine, you have introduced aliasing where you previously had a safe copy. On a message bus that is often a reason to keep values and accept the copy — decide it deliberately rather than by accident. ## Reading only one field A related trap: asserting purely to read one field still copies the whole struct, because the assertion produces the value before the selector runs. If the hot path only needs `ev.(Event).Kind`, and `Event` is large, either box a pointer or give the type a small accessor interface so the field is reached through a method call instead of an unboxing. ## Summary The interface holds a copy; the assertion hands back another copy; the compiler forbids the syntax that would suggest otherwise. Mutations stick only when what is boxed is a pointer.
- Does binding in a type switch avoid the copy?No. `switch e := ev.(type)` with a `case Event:` arm copies the struct out exactly as an assertion does, so writes to `e` are local. A `case *Event:` arm copies only the pointer, and writes through it are visible to every holder. The syntax hides the difference, which is why the arm's type is the thing to read.
- Why is ev.(Event).Retries = 3 a compile error?Because a type assertion yields a value rather than a variable, and Go only allows assignment to something addressable. There is no storage the compiler could name for the assertion's result — writing to it would update a temporary discarded immediately afterwards, so the compiler rejects it instead of silently doing nothing useful.
- When would you keep boxing values rather than pointers on a message bus?When the copy is the safety property you want: each handler gets its own snapshot, so a handler that mutates or retains an event cannot affect other subscribers or the publisher, and there is no data race if handlers run concurrently. Switch to pointers when a measurement shows the copies matter, and then decide explicitly who is allowed to mutate.
saying these in an interview costs you the question
- Says the interface stores a reference to the original struct
- Expects writes through an asserted value to be visible to others
- Thinks a type switch binding avoids the copy
- Believes ev.(Event).Field = x compiles
- Assumes asserting to read one field is free for a large struct