Skip to content

Pointers

A pointer is a variable that holds the memory address of another variable. The type of a pointer to T is written *T.

x := 42
p := &x // & takes the address of x; p has type *int

To read or write through a pointer, dereference it with *:

fmt.Println(*p) // 42 — read through p
*p = 100 // write through p — x is now 100
fmt.Println(x) // 100
OperatorReads asEffect
&x”address of x”produces a *T pointing to x
*p”value at p”dereferences p, reads or writes T

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 := 5
double(&x)
fmt.Println(x) // 10

2. 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 heap
func 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 heap

The 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.

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 = true

The built-in new(T) allocates a zero value of type T and returns *T:

n := new(int) // *int pointing to 0
*n = 7

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 36
q := Person{Name: "Bob", Age: new(yearsSince(born))} // any expression works

&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 Config
p3 := &v
fmt.Println(*p1 == *p2, *p2 == *p3) // true true — all zero-valued
fmt.Println(p1 == p2) // false — two distinct allocations

Verified 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.

The zero value of any pointer type is nil. Dereferencing a nil pointer causes a runtime panic:

var p *int
fmt.Println(p == nil) // true
// *p = 1 would panic: nil pointer dereference

Always check a pointer for nil before dereferencing when it might not have been set.

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 == nil
func (l *List) Sum() int {
if l == nil {
return 0
}
return l.Val + l.Next.Sum()
}
var l *List
fmt.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()) // 3

Do 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.

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 place
s[0], s[1], s[2] = 1, 2, 3
p := &s[1] // points at index 1 of the current backing array
s = append(s, 4) // len(3) == cap(3) -> reallocates to a new backing array
s[1] = 99 // written to the NEW backing array
fmt.Println(*p, s) // p still points at the OLD backing array

Verified 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 i

The 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.

fmt.Printf("%p", p) formats a pointer’s address as hex, which is useful for logging and debugging:

x := 42
p := &x
fmt.Printf("%p\n", p) // 0x... — the actual runtime address, format only

uintptr 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.

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 == nil
func (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
}
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"]
A pointer only forces a heap allocation when escape analysis proves the value must outlive its function
What you getBenefitCost
pointer to large structavoids a copy — only 8 bytes (pointer size) cross the callmay escape to the heap → more GC pressure
value receiverclearly non-mutating, cache-friendlycopies the struct on every method call
pointer receivercan mutate, no copyneeds a nil check, can introduce aliasing bugs
*T = nil as optionalrepresents “no value” without a sentinelmust nil-check before every dereference
  • 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 append reallocates 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=-m shows 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 go directive. Under go 1.22 or later each iteration has its own variable and the capture is correct; under go 1.21 or earlier all iterations share one variable and it is the classic bug. Check go.mod before you judge.
  • uintptr is just another name for a pointer — it is a plain integer as far as the garbage collector is concerned; the GC does not update uintptr values when it moves memory, which is exactly why raw uintptr arithmetic is unsafe.

💡 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.

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 with go build -gcflags=-m if 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 actually return/continue before touching any field.
  • Does anything hold onto &slice[i] or a sub-slice across a call that might append to 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 := i inside the loop body) on a codebase whose go.mod already declares go 1.22 or 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 uintptr as if the garbage collector still knows where it points? A uintptr obtained 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.

You pass x (an int) to func add(n int) which sets n = n + 1. After the call, what is x?
What happens if you dereference a nil pointer in Go?
What does this print? func newConfig() *Config { c := Config{Workers: 4} return &c } fmt.Println(newConfig().Workers)
What does this print? s := make([]int, 3, 3) s[0], s[1], s[2] = 1, 2, 3 p := &s[1] s = append(s, 4) s[1] = 99 fmt.Println(*p, s)
A module's go.mod declares go 1.21, built with a current toolchain. What does this print? 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()) } sort.Ints(got) fmt.Println(got)