Assignment Copies — Then Ask What 'Copy' Means for Your Type
A deployment tool clones a config before stamping environment-specific values. The clone works — until two environments disagree about a shared tag:
type Config struct {
Name string
Tags []string
}
func main() {
base := Config{Name: "api", Tags: []string{"core"}}
prod := base // "independent copy"
prod.Name = "api-prod" // fine — base.Name still "api"
prod.Tags[0] = "urgent" // base.Tags[0] is now "urgent" too!
}
The struct assignment did copy — completely, by the language's rules. What the rules copy is the representation: Name's bytes, and Tags' three-word slice header. The backing array the header points at was never part of the copy, so both structs are windows over one array. Value semantics say "assignment gives you your own value"; for reference-shaped fields, your own header still references shared storage.
Each type has its own answer to "what does copying duplicate":
| Type | Assignment duplicates | Afterwards |
|---|---|---|
| int, string, bool, float | the value | fully independent |
| struct | every field, recursively by these same rules | independent unless a field is reference-shaped |
| array | every element | fully independent |
| slice | the 24-byte header | elements shared until capacity forces a new array |
| map, channel | the reference | same buckets/buffer, entirely shared |
| pointer, func | the address/code ref | same target |
| interface | type word + value word | shared if the dynamic value is reference-shaped |
The practical consequence: a Session struct with a Meta map[string]string field copies as a.Meta["role"] = "x" visible through b — maps inside copied structs share buckets wholesale (append, copy, and Slice Aliasing covers the slice half of this trap). Whenever a copy crosses an ownership boundary — returning a cached struct, storing caller data into your own state — decide whether sharing is intended. If not, clone the reference fields before the handoff. Predicting copies starts with reading your struct field by field, asking of each one: value, or window onto something shared?
Receivers Decide Who Sees the Change
Methods inherit the copy rule through their receivers, and the receiver choice is the API decision Go asks you to make for every type.
type Temperature float64
func (t Temperature) ToF() float64 { // value receiver: works on a copy
return float64(t)*9/5 + 32
}
type Counter struct {
n int
}
func (c *Counter) Inc() { c.n++ } // pointer receiver: mutates the caller's
func (c Counter) Value() int { return c.n } // read-only half may stay a value
Temperature is a logical value — a reading does not change when you convert it, and returning new values (t + 5) rather than mutating keeps it honest. Counter carries mutable state; a value receiver's c.n++ would edit a per-call copy and silently do nothing for the caller. The default rules that keep codebases consistent:
- Pointer receiver when the method mutates, when the type embeds
sync.Mutex(copying locks is a race — Pointers as Function Parameters), or when the struct is large enough that copying per call shows up. - Value receiver for small immutable operations that return new values — numeric wrappers, coordinates, durations.
- Do not mix receiver kinds across one type's methods.
func (c Counter) Reset()besidefunc (c *Counter) Inc()compiles, but callers cannot predict which methods preserve identity, and half the methods stop feeling like they belong to the same type.
Method sets add a hard constraint underneath the style rules: a type T with any pointer-receiver method satisfies interfaces only through *T, because plain T values cannot serve methods that might mutate (Interface Basics). Value-receiver methods, by contrast, belong to both T and *T. This is why Error() on a pointer-receiver error type forces &MyErr{} into returns, and why interface-heavy packages settle receiver kinds early — changing them later is a breaking change to every call site.
Copying Is Sometimes the Entire Feature
The performance reflex — "pointers are faster, so pass pointers" — misreads what value semantics buy. Three production properties come only from copies.
Independence under concurrency. A value passed into two goroutines is two values; neither can observe the other's writes. Race detectors stay quiet because there is nothing shared to race on. A time.Duration or a small config snapshot handed to workers is safe without a mutex precisely because the copy severed the connection.
No aliasing surprises. for _, job := range jobs gives each iteration its own job. If job is a struct whose fields are values, no other iteration can rewrite what you are processing. Aliasing bugs live where reference-shaped fields appear — which is a reason to keep hot-loop structs free of shared slices rather than a reason to distrust structs.
Predictable lifetimes and simple APIs. A function returning Report rather than *Report cannot leave the caller holding a pointer into your internal buffer. The caller cannot mutate your cache by accident. Nothing to nil-check, nothing to clone, nothing that outlives the call.
The costs are equally concrete, and they set the boundary for when to switch to pointers:
type Snapshot struct { // ~10 words: two strings, a slice, a map...
User string
Query string
Results []string
Meta map[string]string
}
func render(s Snapshot) string // copies fields; headers still share storage
func renderSlow(s *Snapshot) // needed only if mutation or size demands it
A ten-word struct by value is a handful of register moves — passing *Snapshot instead trades it for a memory load and a nil obligation. Reach for pointers when the method must mutate, the struct is genuinely large, a lock lives inside, or interfaces require it. Everything else stays a value, field-shared or not, until a benchmark (benchmarks and coverage) says otherwise. The habit to build: before every assignment or call, name what is copied and who shares what — because in Go, that question is answered per type, and half your bugs will be the type you did not think about.
Quiz
1. type C struct { Name string; Tags []string }; a := C{...}; b := a; b.Tags[0] = "x". What happens to a.Tags[0]?
2. func (c Counter) Inc() { c.n++ } — the caller runs ctr.Inc() and reads ctr.n. What does it read?
3. A type has some methods with value receivers and one with a pointer receiver. Which statement is correct?
4. A hot function processes a 3-field struct it never mutates. A colleague switches the parameter to *T 'for performance'. What is the real effect?
Flashcards
Question
What does b := a copy when a is a struct with a string, a slice, and a map field?
Click to reveal answer
Answer
The string header/value fully, so Name is independent. The slice's three words — pointer, len, cap — so the backing array stays shared. The map reference, so both structs read and write the same buckets. Struct copies are complete at the representation level and shallow wherever a field is reference-shaped.
Question
Give the receiver rule for a type whose methods are half arithmetic and half mutators.
Click to reveal answer
Answer
Pick one kind for the type, and mutation decides it: mutators need pointer receivers, so the whole type goes pointer — mixed receivers are legal but break the caller's ability to predict identity, and interfaces containing a pointer-receiver method bind only to *T.
Question
When is copying the feature rather than the cost?
Click to reveal answer
Answer
When you need independence: values shared across goroutines without locks, iteration bodies that cannot be rewritten by other iterations, and API returns that cannot alias your internal buffers. Copies eliminate aliasing and nil-handling instead of adding overhead — that is why Go defaults to value semantics.
Question
A cached User struct is handed to three handlers and one mutates Meta. What went wrong, and how do you fix it at the boundary?
Click to reveal answer
Answer
The struct copy shared the Meta map (and any slice) with the cache, so a handler edit corrupted shared state. Fix at ownership boundaries: clone reference fields before returning (copy the map/slice into fresh storage), or store the cache entry pre-cloned per consumer and document the rest as read-only.
Question
Why can't a value receiver's mutation ever be seen by the caller, even when the struct contains a map?
Click to reveal answer
Answer
The receiver copy duplicates every field — including the map reference. Writes through that reference (m[k] = v) hit shared buckets and do propagate, but rebinding or field assignment on the receiver (m = newMap, c.n++) changes only the copy. Propagation depends on the operation, not on the receiver kind.
Revision Notes
Key Takeaways
- 1. Struct assignment is a full copy of the representation but shallow for slice, map, channel, and interface fields — sharing hides inside 'copies'
- 2. Receiver kind is the API contract: mutation and locks force *T, immutable value types fit T, and mixing kinds confuses method sets
- 3. Pointer-receiver methods exist only in *T's method set, so interface satisfaction can force the receiver choice
- 4. Value semantics are the safe default: independent values under concurrency, no aliasing, nothing to nil-check — pointers are the exception with stated reasons
- 5. Clone at ownership boundaries only; cloning every copy hides the sharing model instead of managing it
Interview Tips
- • "What does assignment copy for a slice versus an array?" — header versus elements; extend to a struct containing both to show the general rule
- • "Value or pointer receiver?" — answer with mutation first, then locks/size, then mention consistency across the type's methods
- • When asked about a mutation that 'doesn't stick', walk the receiver copy diagnosis before blaming the map or the loop
- • "When would you pass by value in a hot path anyway?" — small immutable structs, with benchmarks named as the arbiter, not intuition
Cheat Sheet
Value Semantics and Copies
What assignment duplicates:
| Type | After b := a |
|---|---|
| scalar / string | fully independent |
| struct | field-by-field, each following this table |
| array | fully independent |
| slice | header only — backing array shared |
| map / channel | reference — fully shared |
| pointer / func | same target |
| interface | type + value word; shared if dynamic value is reference-shaped |
Receiver decision:
| Situation | Receiver |
|---|---|
| mutates state | *T |
| embeds sync.Mutex / large struct | *T |
| arithmetic returning new values (Money, Vec) | T |
| any pointer-receiver method exists | *T for all methods |
| satisfies an interface with pointer-only methods | must use *T |
Code template:
// Ownership boundary: clone what you hand out.
func (c *Cache) User(id string) (User, bool) {
u, ok := c.byID[id]
if !ok {
return User{}, false
}
u.Tags = append([]string(nil), u.Tags...) // detach from the cache
u.Meta = maps.Clone(u.Meta) // maps share buckets on copy
return u, true
}
Checklist before any assignment or call: name what is copied · name who still shares storage · clone only at ownership boundaries, not everywhere.
Continue Learning
Before You Start
- No prerequisites listed.