Pointers
What is a pointer?
Section titled “What is a pointer?”A pointer is a variable that holds the memory address of another variable. The type of a pointer to T is written *T.
x := 42p := &x // & takes the address of x; p has type *intTo read or write through a pointer, dereference it with *:
fmt.Println(*p) // 42 — read through p*p = 100 // write through p — x is now 100fmt.Println(x) // 100& and * are inverse operations
Section titled “& and * are inverse operations”| Operator | Reads as | Effect |
|---|---|---|
&x | ”address of x” | produces a *T pointing to x |
*p | ”value at p” | dereferences p, reads or writes T |
When to use pointers
Section titled “When to use pointers”1. Mutation across function boundaries. Go passes all arguments by value. If a function needs to modify the caller’s variable, pass a pointer:
func double(n *int) { *n = *n * 2}x := 5double(&x)fmt.Println(x) // 102. Large structs. Copying a large struct on every call is wasteful. Pass *Config instead of Config when the struct has many fields.
3. Optional values. A *T can be nil to represent “not set”, similar to null in other languages. This is the classic use for something like *bool (three states: true, false, unset) — though when the type you would otherwise wrap is itself a struct or a time.Time, a dedicated “is this present” convention like encoding/json’s omitzero (Go 1.24) can avoid the pointer and its nil checks entirely; see the stdlib module for that comparison.
Escape analysis: why a pointer isn’t always faster
Section titled “Escape analysis: why a pointer isn’t always faster”“Pass a pointer to avoid a copy” is true, but it trades one cost for a different one: a value that a pointer points to has to live somewhere that outlives the function call if the pointer escapes the function — and that “somewhere” is the heap, which the garbage collector then has to track. The compiler decides this automatically with escape analysis: if it can prove a value never outlives its function, it stays on the stack (free to allocate, free to reclaim); if not, it “escapes” to the heap.
type Config struct{ Workers int }
// newConfig returns a pointer to a local variable — it must escape to the heapfunc newConfig() *Config { c := Config{Workers: 4} return &c}go build -gcflags=-m prints the compiler’s own reasoning. Verified on 1.26.5:
./main.go:8:6: can inline newConfig./main.go:13:6: can inline sum./main.go:18:18: inlining call to newConfig./main.go:19:30: inlining call to sum./main.go:19:13: inlining call to fmt.Println./main.go:9:2: moved to heap: c./main.go:19:13: ... argument does not escape./main.go:19:17: cfg.Workers escapes to heap./main.go:19:30: ~r0 escapes to heapThe line that matters is moved to heap: c — the compiler is telling you exactly why newConfig cannot keep c on the stack: its address is returned, so it must outlive the function. This is why “return &T{} everywhere, just in case” — a pattern AI-generated Go leans on heavily because it looks defensively safe — is not free: every one of those pointers is a heap allocation and GC-tracked object, even when the caller only ever reads one field once and discards it. Returning a value (Config, not *Config) when the struct is small and the caller does not need to mutate the original lets the compiler keep it on the stack.
Struct pointer shorthand
Section titled “Struct pointer shorthand”When you have a pointer to a struct, Go automatically dereferences it for field access. You do not need to write (*cfg).Debug:
cfg := &Config{Workers: 4}cfg.Debug = true // shorthand for (*cfg).Debug = truenew allocates a zeroed value
Section titled “new allocates a zeroed value”The built-in new(T) allocates a zero value of type T and returns *T:
n := new(int) // *int pointing to 0*n = 7new with an initial value (Go 1.26)
Section titled “new with an initial value (Go 1.26)”Since Go 1.26 new also accepts an expression and returns a pointer to a copy of its value. This removes the old two-step dance of declaring a variable just to take its address, which is common with optional struct fields:
type Person struct { Name string Age *int // optional}
yearsSince := func(y int) int { return 2026 - y }born := 1990
p := Person{Name: "Ada", Age: new(36)} // *int pointing to 36q := Person{Name: "Bob", Age: new(yearsSince(born))} // any expression worksThree ways to get a zero-valued pointer
Section titled “Three ways to get a zero-valued pointer”&T{}, new(T), and var t T; &t all produce a pointer to an equivalent zero value — but they are two distinct allocations if you compare them by identity:
p1 := &Config{}p2 := new(Config)var v Configp3 := &v
fmt.Println(*p1 == *p2, *p2 == *p3) // true true — all zero-valuedfmt.Println(p1 == p2) // false — two distinct allocationsVerified on 1.26.5: *p1 == *p2 and *p2 == *p3 are both true (dereferenced, they’re equal zero values), but p1 == p2 is false (they are different addresses — new and &T{} never return the same pointer). Style-wise, &T{} is the idiomatic choice when you also want to set some fields inline (&Config{Workers: 4}); new(T) reads slightly clearer when you truly want only the zero value and nothing else. Both escape to the heap under the same rules as any other pointer — see escape analysis above.
Nil pointers
Section titled “Nil pointers”The zero value of any pointer type is nil. Dereferencing a nil pointer causes a runtime panic:
var p *intfmt.Println(p == nil) // true// *p = 1 would panic: nil pointer dereferenceAlways check a pointer for nil before dereferencing when it might not have been set.
Nil pointer receivers are legal Go
Section titled “Nil pointer receivers are legal Go”Calling a method on a nil pointer receiver does not panic by itself — Go only panics the moment the method body actually dereferences the receiver. A method that checks for nil first and returns early is completely legal, and this is a common, idiomatic pattern for recursive structures like linked lists and trees:
type List struct { Val int Next *List}
// Sum works on a nil *List receiver — no field is dereferenced when l == nilfunc (l *List) Sum() int { if l == nil { return 0 } return l.Val + l.Next.Sum()}
var l *Listfmt.Println(l.Sum()) // 0 — calling a method on a nil pointer is legal
l = &List{Val: 1, Next: &List{Val: 2}}fmt.Println(l.Sum()) // 3Do not confuse this with the typed-nil interface trap: a nil *List stored in an interface{}/error/other interface value is a non-nil interface holding a nil concrete pointer, and iface == nil is false even though the pointer inside it is nil. That trap is covered in depth in Error Values — the rule here about calling methods on a nil receiver being legal is a completely different, narrower point about pointers, not interfaces.
&s[i] after append can go stale
Section titled “&s[i] after append can go stale”A pointer into a slice’s backing array is only valid as long as that backing array is the one the slice still uses. If append triggers a reallocation, the old pointer keeps pointing at the abandoned array:
s := make([]int, 3, 3) // len == cap: no room to grow in places[0], s[1], s[2] = 1, 2, 3p := &s[1] // points at index 1 of the current backing array
s = append(s, 4) // len(3) == cap(3) -> reallocates to a new backing arrays[1] = 99 // written to the NEW backing array
fmt.Println(*p, s) // p still points at the OLD backing arrayVerified on 1.26.5, this prints 2 [1 99 3 4] — *p still reads 2, the value from before the reallocation, even though s[1] is now 99. &s[i] is only safe to hold onto across an append call if you know for certain the slice will not grow past its capacity. This is the pointer-shaped mirror of the slice-aliasing hazard from the Slices lesson — and &m[k] for a map is not even legal to write in the first place, because map values can be relocated by a rehash at any time (see Maps).
Pointer to a loop variable: before and after Go 1.22
Section titled “Pointer to a loop variable: before and after Go 1.22”Before Go 1.22, a for loop’s index/range variable was a single variable shared by every iteration — a closure created inside the loop body captured that one variable, not a per-iteration snapshot. The classic bug: launching a goroutine or storing a closure per iteration, then having all of them observe the loop’s final value once the loop finished, because they all shared the same variable.
Since Go 1.22, each iteration gets its own copy of the loop variable, which is what the following prints on any current toolchain:
funcs := make([]func() int, 0, 3)for i := 0; i < 3; i++ { funcs = append(funcs, func() int { return i })}got := make([]int, 0, 3)for _, f := range funcs { got = append(got, f())}fmt.Println(got) // [0 1 2] — each closure captured its own iThe change is gated by the go line in go.mod, so existing code does not silently change meaning: a module declaring go 1.21 or earlier keeps the old shared-variable behavior, and go 1.22 or later gets the per-iteration one. Verified on a Go 1.27.0 toolchain by building the snippet above under both: go 1.21 prints [3 3 3], go 1.22 prints [0 1 2]. That is why the fix for old code is either to raise the go directive or to keep the i := i line — and why you must read a repository’s go.mod before judging whether a loop-variable capture in it is a bug.
%p and uintptr are not the same thing
Section titled “%p and uintptr are not the same thing”fmt.Printf("%p", p) formats a pointer’s address as hex, which is useful for logging and debugging:
x := 42p := &xfmt.Printf("%p\n", p) // 0x... — the actual runtime address, format onlyuintptr looks similar (it is, structurally, just an integer holding an address) but it is not a pointer as far as the garbage collector is concerned. The GC only tracks and updates values of pointer types when it moves memory; a uintptr is invisible to it. Converting a pointer to uintptr, doing arithmetic, and converting back is exactly the kind of unsafe operation that can end up pointing at freed or moved memory — the unsafe package’s own documentation requires very specific patterns (used immediately, never stored) for this to be safe. Application code essentially never needs uintptr; it exists for the runtime and for narrow FFI/interop cases.
No pointer arithmetic
Section titled “No pointer arithmetic”Go deliberately omits pointer arithmetic. You cannot do p++ or p + 4 to walk through memory. This eliminates an entire class of memory-safety bugs. The unsafe package provides pointer arithmetic as an escape hatch, but it is almost never needed in application code.
package main
import "fmt"
type Config struct { Debug bool Workers int}
func enableDebug(cfg *Config) { cfg.Debug = true // mutates the caller's Config}
func double(n *int) { *n = *n * 2}
func main() { // & takes the address; * dereferences x := 42 p := &x fmt.Println(*p) // 42 *p = 100 fmt.Println(x) // 100 -- x was mutated through p
// Pointer for mutation across function boundary double(&x) fmt.Println(x) // 200
// Pointer to struct cfg := Config{Debug: false, Workers: 4} enableDebug(&cfg) fmt.Println(cfg.Debug) // true
// new() allocates a zeroed value and returns a pointer n := new(int) *n = 7 fmt.Println(*n) // 7
// nil pointer var ptr *int fmt.Println(ptr == nil) // true} The second playground focuses on the depth topics above: the three ways to get a zero-valued pointer, nil pointer receivers, and a stale pointer after append reallocates.
package main
import "fmt"
type Config struct{ Workers int }
type List struct { Val int Next *List}
// Sum works on a nil *List receiver — no field is dereferenced when l == nilfunc (l *List) Sum() int { if l == nil { return 0 } return l.Val + l.Next.Sum()}
func main() { // &T{}, new(T), and var t T; &t all produce an equivalent zero value p1 := &Config{} p2 := new(Config) var v Config p3 := &v fmt.Println(*p1 == *p2, *p2 == *p3) // true true fmt.Println(p1 == p2) // false — two distinct allocations
// Calling a method on a nil pointer receiver is legal Go var l *List fmt.Println(l.Sum()) // 0
l = &List{Val: 1, Next: &List{Val: 2}} fmt.Println(l.Sum()) // 3
// &s[i] after append can point at a stale backing array s := make([]int, 3, 3) // len == cap: no room to grow in place s[0], s[1], s[2] = 1, 2, 3 old := &s[1]
s = append(s, 4) // reallocates: s now points at a new backing array s[1] = 99 // written to the NEW array only
fmt.Println(*old, s) // old still reads from the abandoned array} Mental Model
Section titled “Mental Model”flowchart LR
subgraph Stack["Stack (per-goroutine, freed on return)"]
x["x := 42"]
p["p := &x"]
cfg["cfg"]
end
subgraph Heap["Heap (GC-managed)"]
c["Config{Workers: 4}"]
end
p -.->|"&x: does not escape, stays on stack"| x
cfg -->|"cfg points into the heap"| c
newConfig["newConfig() returns &c"] -->|"c escapes: outlives the function"| c
Note["stack alloc is fast and GC-free; heap alloc costs GC-managed latency; escape analysis decides which"] Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| pointer to large struct | avoids a copy — only 8 bytes (pointer size) cross the call | may escape to the heap → more GC pressure |
| value receiver | clearly non-mutating, cache-friendly | copies the struct on every method call |
| pointer receiver | can mutate, no copy | needs a nil check, can introduce aliasing bugs |
*T = nil as optional | represents “no value” without a sentinel | must nil-check before every dereference |
Common Misconceptions
Section titled “Common Misconceptions”- Pointers in Go are as dangerous as in C — Go has no pointer arithmetic and the GC manages lifetime for you — use-after-free is impossible. But a pointer that still points at an old element after
appendreallocates the backing array is still a real bug (see “&s[i]after append can go stale” above). - A pointer is always faster than a value — a pointer can make a variable escape to the heap → more work for the GC → higher latency;
go build -gcflags=-mshows you exactly when this happens. - A nil pointer is the same problem as a NullPointerException in Java — Go’s panic carries a clear stack trace, and nil is trivial to check for with
if p == nil. Calling a method on a nil pointer receiver does not even panic by itself — only dereferencing a field through it does. - A closure capturing a loop variable is always a bug — it depends on the module’s
godirective. Undergo 1.22or later each iteration has its own variable and the capture is correct; undergo 1.21or earlier all iterations share one variable and it is the classic bug. Checkgo.modbefore you judge. uintptris just another name for a pointer — it is a plain integer as far as the garbage collector is concerned; the GC does not updateuintptrvalues when it moves memory, which is exactly why rawuintptrarithmetic isunsafe.
💡 Real-world example
The Go standard library uses pointer receivers for stateful types, e.g.
(*bytes.Buffer).Write()— so it can maintain internal state across calls.
Reading AI-generated code
Section titled “Reading AI-generated code”Pointer-related bugs are rarely a crash right where the mistake was made — they surface somewhere else entirely. Check for these:
- Does a function return
&LocalStruct{...}from every code path “just in case the caller needs to mutate it,” when most callers only read one field? That is a heap allocation on every call — check withgo build -gcflags=-mif this function is ever a hot path. - Does a method with a pointer receiver dereference a field before checking the receiver for
nil? A nil-receiver check has to come first, and it has to actuallyreturn/continuebefore touching any field. - Does anything hold onto
&slice[i]or a sub-slice across a call that mightappendto the same slice? Reallocation silently detaches that pointer from the slice’s current backing array. - Does a closure created inside a loop assume it needs Go-1.21-style workarounds (
i := iinside the loop body) on a codebase whosego.modalready declaresgo 1.22or later? That workaround is dead code on a modern language version — harmless, but a sign the author (or the AI) didn’t know the fix already shipped. - Does any code compare or format a
uintptras if the garbage collector still knows where it points? Auintptrobtained from a pointer is only valid for the instant it was taken — the object underneath can move before the next line runs.
🐛 Spot the bug
type Cache struct { mu sync.Mutex data map[string]int}
func (c *Cache) Get(key string) (int, bool) { if c == nil { return 0, false } c.mu.Lock() defer c.mu.Unlock() v, ok := c.data[key] return v, ok}
var globalCache *Cache
func main() { v, ok := globalCache.Get("x") fmt.Println(v, ok)}Show the answer
This one is actually correct, and that is the point of the exercise — Get’s nil check comes first and returns before touching c.mu or c.data, so calling it on a nil *Cache (as main does with the zero-valued globalCache) is completely legal and prints 0 false. The bug-pattern to watch for is the same shape with the check in the wrong place, or missing: if c.mu.Lock() ran before the nil check, the exact same call would panic with nil pointer dereference because Lock() needs to read c.mu’s address, which requires c to be non-nil first. When reviewing generated code with a nil-receiver guard, confirm the guard is the very first statement in the method — not just present somewhere in the function.