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

Pointers as Function Parameters

Pass references to avoid copies while keeping mutation explicit at call sites.

40m
0 problems
Topic Progress 0%

The Function That Updated Everything But Its Argument

A config applier fills in defaults. The first draft compiles, runs, and does nothing:

type Config struct {
	Timeout time.Duration
	Retries int
}

func applyDefaults(c Config) {
	if c.Timeout == 0 {
		c.Timeout = 5 * time.Second
	}
	if c.Retries == 0 {
		c.Retries = 3
	}
}

func main() {
	cfg := Config{}
	applyDefaults(cfg)
	fmt.Println(cfg) // {0 0} — defaults disappeared
}

The parameter is a value, so the function received a copy of cfg and diligently edited the copy. Nothing about applyDefaults is wrong except its signature. Three lines fix it:

func applyDefaults(c *Config) {
	if c.Timeout == 0 {
		c.Timeout = 5 * time.Second
	}
	if c.Retries == 0 {
		c.Retries = 3
	}
}

applyDefaults(&cfg) // caller passes an address; write the line that shows intent
fmt.Println(cfg)    // {5s 3}

Inside, c.Timeout is unchanged syntax — field selection auto-dereferences the pointer (Addresses and Pointers) — but the write now lands in the caller's storage. Two things changed hands: the parameter type and the call site. That pairing matters more than it looks. Seeing applyDefaults(&cfg) tells a reader mutation is on the menu; applyDefaults(cfg) reads as a read-only operation. The address at the call site is the mutation warning label, which is why Go APIs prefer explicit pointers over hidden out-parameters.

The mirror case is a function that must report a failure through the argument rather than a return value:

func lookup(rows []Row, key string, found *bool) (Row, error) {
	for _, r := range rows {
		if r.Key == key {
			*found = true
			return r, nil
		}
	}
	*found = false
	return Row{}, ErrNotFound
}

This shape works but shows its age — the comma-ok idiom (Maps: Insert, Lookup, and Delete) handles "absent" without an extra parameter. Pointer parameters are at their strongest when the mutation is the point: counters, builders, and in-place updates that must reach one shared entity.

Two Reasons to Take a Pointer, Two Reasons Not To

The decision has fewer branches than people expect.

Take a pointer when the function must mutate the caller's value. No alternative does this cleanly — returning a replacement works for one value but collapses when several functions must coordinate updates to the same entity.

Take a pointer to avoid copying something large. A Config with a map, a slice, and six strings is a dozen words to copy; a [4096]byte buffer passed by value would copy four kilobytes per call. Structs past a screenful default to *T parameters and pointer receivers, and the compiler's -gcflags=-m output will show what copies and what escapes if you want evidence.

Do not take a pointer for one-word values the function does not mutate. func clamp(v int, lo, hi int) int passes three machine words in registers — a *int version adds a load from memory and a nil obligation for zero benefit. Value semantics for small immutable inputs is the Go default for a reason.

Never pass sync.Mutex (or anything containing one) by value. Copying a mutex copies its locked state, handing two objects the illusion of one lock:

type Registry struct {
	mu    sync.Mutex
	items map[string][]string
}

func (r Registry) Add(k, v string) { // vet: "passes lock by value"
	r.mu.Lock()         // locks a COPY of the mutex
	defer r.mu.Unlock() // caller's mutex never engaged — data race
	r.items[k] = append(r.items[k], v)
}

go vet flags this as copylocks, CI should run vet, and the fix is func (r *Registry) Add. The same rule covers sync.WaitGroup, sync.Once, and any struct embedding them.

One more structural reason for pointer parameters: interface satisfaction. A type whose methods take pointer receivers satisfies interfaces with those methods only through *T, so a function storing values in a map[string]Notifier will not accept plain T values (Interface Basics). When in doubt, mutation first, size second — and never copy a lock.

Slices and Maps: Shared Storage Without an Ampersand

Newcomers expect that only *T parameters share state with the caller. Slices and maps complicate the picture, because their headers or internals already reference shared storage — and the details decide which of your edits survive.

func paint(colors []string, name string) {
	colors[0] = name          // (1) caller SEES this
	colors = append(colors, name) // (2) caller may NOT see this
	colors = colors[1:]       // (3) caller does NOT see this
}

func bump(counts map[string]int) {
	counts["x"]++             // caller SEES this — no * needed
	counts = nil              // caller does NOT see this
}

The rule that explains all of it: element storage is shared, headers are copied. The caller and the function hold two headers pointing at one array, so colors[0] = name writes through both. But append and reslicing reassign the function's local header — and when append outgrows capacity, it allocates a new array entirely, so the caller keeps the old one (append, copy, and Slice Aliasing). A map's counts = nil rebinds the local copy of the map reference the same way; mutations of entries always propagate, rebinding never does.

Because append can silently detach, the contract for growing a caller's slice runs through the return value:

// Wrong: growth lost when capacity was exceeded.
func addTagged(items []string, tag string) { items = append(items, tag) }

// Right: the new header goes back to the caller.
func addTagged(items []string, tag string) []string {
	return append(items, tag)
}

caller = addTagged(caller, "urgent")

Even the "right" version carries the aliasing caveat: if the append fit in shared capacity, the caller's array gained an element it may not know about. That is harmless when the caller follows the return value, and a reason to seal boundaries with s[i:j:j] when a function must never write back.

As a caller, the practical checklist: pass a *T when you want mutation stated outright; expect element-level visibility from slices and maps without any star; and always consume returned headers from functions that grow collections. What remains — how value receivers and pointer receivers choose the same contract for methods — is Value Semantics and Copies.

Optional Inputs: Where a Pointer Parameter Is Uniquely Right

Some parameters are conditional: patch an entity, but only touch the fields the client actually sent. With plain values, "client sent 0" and "client sent nothing" are the same bytes, and the handler cannot honor the difference. A *T field makes absence a first-class value:

type UserPatch struct {
	Name   *string // nil = leave unchanged
	Active *bool   // nil = leave unchanged
}

func apply(current *User, p UserPatch) {
	if p.Name != nil {
		current.Name = *p.Name
	}
	if p.Active != nil {
		current.Active = *p.Active
	}
}

A request that only disables an account sends UserPatch{Active: &false} — and false survives the trip because the pointer distinguishes "explicitly false" from "not mentioned." This is the standard shape for partial updates, optional overrides in API handlers, and context-style tunables when a struct of options must carry maybe-values.

Two design habits keep it clean. First, convert *T to a value at the boundary: once the nil check passes, work with *p.Name locally rather than passing the pointer deeper — the rest of the function deals in strings, not in references. Second, prefer a nil-pointer check over sentinel values where the type allows it:

func priceOf(rate *float64) float64 {
	if rate == nil {
		return defaultRate
	}
	return *rate
}

Contrast priceOf(-1.0) as the "use default" signal — a sentinel that collides with legitimate input. Pointer absence has no such collision because no float value produces a nil pointer.

The pattern has a boundary: interfaces do not need it (a nil interface or a typed nil needs its own handling — Nil Checks and Zero-Value Design), and for many parallel optional fields the alternatives — functional options (WithTimeout(5*time.Second)) or a defaults struct — read better than a wall of *Ts. Use pointer optionals where the field count is small and the unset-vs-zero distinction is real; that is exactly where value parameters fail.

Quiz

1. func bump(n int) { n++ } is called as bump(count). What does the caller's count hold afterward?

Question 1 options

2. A method locks a mutex, but go vet reports 'passes lock by value'. What is the real-world failure?

Question 2 options

3. A function does m["k"] = v and s = append(s, v) on its map and slice parameters. What does the caller observe?

Question 3 options

4. An API must honor 'name not provided' differently from 'name is empty string'. Which signature is correct?

Question 4 options

Flashcards

Question

What are the two legitimate reasons to accept *T instead of T as a parameter?

Answer

Mutation: the function must change the caller's value, and no return-based alternative scales beyond one value. Size: copying a large struct per call wastes work, so a one-word pointer replaces a multi-word copy. Small read-only values should stay by value.

Question

Why does Go put &cfg at the call site of mutating functions?

Answer

It is a readability contract: applyDefaults(&cfg) advertises that the argument may change, while applyDefaults(cfg) reads as observation. The signature cannot force intent into a reader's mind, but the address at the call site does — mutation is explicit where the decision is made.

Question

A coworker's func fill(s []string) appends 100 items and the caller gets an empty slice back. Explain, with the capacity rule.

Answer

fill edits its local header; the caller's header never changes regardless. Worse, appends that exceed shared capacity allocate a fresh array, so even the elements written in shared storage stop matching. The fix is returning append's result: func fill(s []string) []string.

Question

Why can't a plain string parameter model an optional override?

Answer

Every string value — including "" — is a valid input, so 'not provided' has no representation; handlers fall back to sentinels like "-" that collide with real data. *string adds exactly one extra state, nil, that no string can assume, making absence checkable and unambiguous.

Question

Which sync types must never be passed or received by value, and what does the failure look like?

Answer

Mutex, RWMutex, WaitGroup, Once, and any struct embedding them. Passing by value copies lock state; the copy's Lock/Unlock cycle leaves the original free, so two goroutines believe they hold the same lock. go vet flags it as copylocks; at runtime it shows up as a data race or corrupted state, not a panic.

Revision Notes

Key Takeaways

  • 1. Pointer parameters exist for mutation and for avoiding large copies — small read-only values should stay by value
  • 2. The & at the call site is the reader's mutation warning; changing a signature to *T means changing every call site too
  • 3. Slices and maps share element storage without an ampersand, but header changes and rebinding stay local — growth must be returned
  • 4. Passing a lock by value defeats it entirely; vet's copylocks check is the safety net, pointer receivers are the fix
  • 5. *T optional fields are the precise way to model absent-vs-zero; unwrap after the nil check and work in values below

Interview Tips

  • • "When do you use pointer parameters?" — mutation first, large-copy avoidance second, then volunteer the counterexamples (small values, locks)
  • • Show you know slices need no star for element writes but do need returned headers for growth — that distinction separates real experience from theory
  • • If asked about optional API fields, present *T with the nil check, then mention functional options as the alternative for many knobs
  • • "How do you find copy bugs?" — name go vet / copylocks; mentioning the tool beats describing the problem

Cheat Sheet

Pointers as Function Parameters

Choose by contract:

Situation Signature Why
must mutate caller's value f(c *Config) only way to write through
large struct, read-mostly f(s *Big) one word instead of a copy
small immutable input f(n int) register pass, no nil to check
grows a collection f(s []T) []T header leaves via return value
optional override f(v *T) nil = absent, distinct from zero
contains sync.Mutex f(m *M) required copying a lock breaks it

What the caller sees:

Parameter kind Element writes Header/rebind changes
T no (copy) no
*T yes yes
slice/map yes (shared storage) no — consume returned headers

Code template:

func applyPatch(u *User, p UserPatch) {
	if p.Name != nil {
		u.Name = *p.Name // unwrap at the boundary, use values below
	}
	if p.Active != nil {
		u.Active = *p.Active
	}
}

func grow(items []string, more ...string) []string {
	return append(items, more...) // growth travels back by return
}

Watch for: forgotten & at call sites when signatures change · unassigned append results · go vet copylocks on any lock-bearing struct.

Continue Learning

Before You Start

  • No prerequisites listed.

Related Reading