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.
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?
2. A method locks a mutex, but go vet reports 'passes lock by value'. What is the real-world failure?
3. A function does m["k"] = v and s = append(s, v) on its map and slice parameters. What does the caller observe?
4. An API must honor 'name not provided' differently from 'name is empty string'. Which signature is correct?
Flashcards
Question
What are the two legitimate reasons to accept *T instead of T as a parameter?
Click to reveal answer
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?
Click to reveal answer
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.
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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.