Two Names, One Value
A metrics collector increments three counters and reports them at shutdown. The counters live in the collector, but one function does the counting. With values alone, every increment dies with its parameter copy — so you need a second name for the same memory:
func main() {
events := 0
p := &events // p has type *int: "pointer to int"
*p = *p + 1 // read the value, add one, write it back
*p++
fmt.Println(events) // 1 — the variable itself changed
}
Read the symbols carefully, because each does one job:
&events— address-of: produce the memory address whereeventslives. The result's type is*int.p— a normal variable whose value happens to be an address.*p— dereference: at addressp, treat the bytes as anint. On the left of=, it is a write target; on the right, a read.
An address is only useful with a type attached. *int says "an address plus the promise that an int lives there," which is what lets the compiler turn *p into a legal read or write. Without that promise, an address is just a number you cannot safely touch.
Every pointer type has a nil zero value — var p *int starts as nil, meaning "points at nothing." Dereferencing nil panics at runtime, so the shape if p != nil { use *p } appears throughout pointer-reading code (Nil Checks and Zero-Value Design).
A pointer is one machine word and copies like any other value: assigning q := p copies the address, so p and q name the same target. That sharing is the entire reason pointers exist — one value, many names, every name's writes visible through the others. The rest of this phase is mostly deciding when that sharing is what you want.
Pointers to Structs: The Language Skips the Star for You
Struct updates are where pointers earn daily use, and Go spares you most of the ceremony. Given a player type, damage looks like this:
type Player struct {
Name string
HP int
}
func damage(p *Player, amount int) {
p.HP -= amount // identical to (*p).HP — the parens are optional
}
func main() {
hero := &Player{Name: "mira", HP: 100}
damage(hero, 30)
fmt.Println(hero.HP) // 70
}
p.HP compiles without writing (*p).HP because field selection on a pointer auto-dereferences. The same applies to method calls: hero.Stats() works whether hero is a Player or a *Player, and the compiler inserts the star for you. ((*p).HP still matters when combining operators — *p.HP parses as *(p.HP), which usually is not what you meant.)
Taking the address of a composite literal is the everyday construction idiom:
cfg := &Config{
Timeout: 5 * time.Second,
Retries: 3,
} // no temporary variable needed
Compare the alternatives to see what the pointer buys. Without it, damage would need the struct, the change, and a return channel: func damage(p Player, amount int) Player. Returning modified copies works but forces every caller to reassign, breaks down when several functions must update one entity, and cannot express "update the thing behind this interface" at all. With *Player, the caller keeps one canonical value and every function that receives its address edits that same record.
Two practical notes on ownership. First, a pointer parameter promises the target outlives the call — pointing at a loop-local variable is fine in modern Go because the variable escapes to the heap when addressed (Heap Escape Analysis), but pointing at an array element inside a buffer you later shift will leave the pointer aimed at whatever moves into that slot. Second, choose one representation per API: functions in the same package should agree on whether they take Player or *Player, because mixing forces callers to sprinkle & and * at random call sites. The receiver-kind question in Value Semantics and Copies takes this much further.
What Pointers Are Not: No Arithmetic, No Free, No Fear
Anyone arriving from C expects four things from pointers. Go deliberately provides none of them, and the absences are features.
No pointer arithmetic. p + 1 does not compile — there is no p[1] via address math, no walking a struct by its byte size:
x := 10
p := &x
fmt.Println(*p + 1) // fine: arithmetic on the VALUE
// p + 1 // invalid operation: p + 1 (mismatched types *int and int)
Indexing exists only where the language defines it: through slices and arrays, with bounds checks attached. Memory safety here does not come from programmer discipline; it comes from the compiler refusing to compute addresses for you.
No manual free. Pointers do not dangle after their target is collected — the garbage collector keeps any object reachable through a pointer alive, and reclaims it when nothing points anymore. There is no free(p), no use-after-free, and no double-free. The tradeoff is latency and a runtime, which the language accepts for you.
Comparison is shallow and nil is checkable. p == q compares addresses — true when both point at the same target or both are nil:
func isSet(p *int) bool { return p != nil }
That three-character check is the gatekeeper before every dereference of untrusted pointer input.
Returning addresses to locals is legal. This surprises people who remember C's stack rules:
func average(samples []float64) *float64 {
if len(samples) == 0 {
return nil // caller must handle the nil case
}
var sum float64
for _, s := range samples {
sum += s
}
avg := sum / float64(len(samples))
return &avg // compiler moves avg to the heap; no dangling reference exists
}
The compiler tracks where values are referenced and allocates them where they need to live — the escape analysis pass that also decides what burdens the GC (Heap Escape Analysis). What you must still provide is the nil contract: returning *float64 makes "no samples" representable as nil, and callers who dereference without checking get a panic. Pointer fundamentals end where parameter design begins: deciding not just how a function receives an address, but what it owes the caller in return.
Quiz
1. count := 0; p := &count; *p = *p + 1. What is count afterward?
2. p is a *Player and you write p.HP -= 10. What does the compiler do?
3. Which expression does Go's type system reject outright?
4. func f() *int { x := 42; return &x } — what is true at the call site?
Flashcards
Question
In one sentence each: what do &x, *T, and *p mean in declaration, assignment, and expression position?
Click to reveal answer
Answer
&x takes the address of x, yielding *T where T is x's type. *T in a type position declares 'pointer to T'. *p in an expression dereferences: it reads the T stored at that address, and on the left of = it selects that storage as a write target.
Question
Why does p.HP compile when p is *Player, and when do you still need parentheses?
Click to reveal answer
Answer
Field and method selection on a pointer auto-dereferences, so p.HP means (*p).HP. Parentheses matter when combining operators with different precedence — *p.HP parses as *(p.HP), so writing (*p).HP is required to dereference first and then select.
Question
Name three safety properties Go pointers inherit from having no pointer arithmetic and no free().
Click to reveal answer
Answer
You cannot form an address outside an object the language gave you (no out-of-bounds pointer math), you never free memory manually (no use-after-free or double-free), and the garbage collector keeps any object reachable through a pointer alive until nothing references it.
Question
Is returning &local a bug in Go? What makes it safe, and what contract remains?
Click to reveal answer
Answer
It is safe: escape analysis detects that the address outlives the frame and allocates the variable on the heap, so no dangling pointer can form. The remaining contract is nil — returning *T means callers must handle a nil result before dereferencing.
Question
When is a pointer parameter required rather than merely efficient?
Click to reveal answer
Answer
When the function must mutate the caller's variable — a counter, a struct field, a found-flag — because a value parameter writes to a private copy. Efficiency is the second reason (large structs), but mutation is the one that cannot be achieved any other way short of returning a replacement value.
Revision Notes
Key Takeaways
- 1. A pointer is an address plus a type: & takes addresses, *p reads or writes the target, and *T is how the type is written
- 2. Field and method selection auto-dereferences, so p.HP and (*p).HP are identical — parentheses only matter when mixing operators
- 3. Go pointers have no arithmetic and no manual free: safety comes from bounds-checked constructs and garbage collection
- 4. Returning &local is legal because escape analysis moves the variable to the heap; the caller's obligation is the nil check
- 5. Pointers exist for shared identity — when all names must observe one value's mutations, that is the requirement, not an optimization
Interview Tips
- • "What's the difference between *T as a type and *p as an expression?" — type position declares, expression position dereferences
- • When asked 'why use a pointer receiver?', lead with mutation visibility, then mention avoiding a copy of a large struct
- • Be ready for 'is returning a pointer to a local safe?': yes, escape analysis heap-allocates it — then note the nil contract you owe callers
- • "What happens on nil dereference?" — runtime panic, not a compile error; show the p != nil guard as the standard shape
Cheat Sheet
Addresses and Pointers
Symbol table:
| Code | Reads as | Type / effect |
|---|---|---|
&x |
address of x | *typeof(x) |
var p *T |
pointer to T, nil zero value | *T |
*p (read) |
value at the address | T |
*p = v (write) |
store through the address | modifies target |
p.HP |
auto-deref field select | same as (*p).HP |
p == nil |
points at nothing? | legal, shallow compare |
Hard limits: no p+1 arithmetic · no free · nil dereference panics at runtime · p == q compares addresses only.
Code template:
type Counter struct {
Name string
Count int
}
func (c *Counter) Inc(by int) { // pointer receiver: caller sees the change
c.Count += by
}
func find(items []string, want string) (*string, bool) {
for i := range items {
if items[i] == want {
return &items[i], true // address into existing storage
}
}
return nil, false // nil + ok: caller checks before deref
}
Rules of thumb: address shared state you intend to mutate · dereference only after a nil check on untrusted pointers · one receiver/parameter kind per type — mixing T and *T at call sites confuses every caller.