Skip to content
intermediate Phase 6 · Pointers, Allocation, and Memory Semantics

Value Semantics and Copies

Predict what assignment and method calls copy before debugging aliasing bugs.

45m
0 problems
Topic Progress 0%

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() beside func (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]?

Question 1 options

2. func (c Counter) Inc() { c.n++ } — the caller runs ctr.Inc() and reads ctr.n. What does it read?

Question 2 options

3. A type has some methods with value receivers and one with a pointer receiver. Which statement is correct?

Question 3 options

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?

Question 4 options

Flashcards

Question

What does b := a copy when a is a struct with a string, a slice, and a map field?

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.

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?

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?

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?

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.

Related Reading