Skip to content
CalliCoder

Playing with Pointers in Golang

Golang 11 min read

Declaring, dereferencing and passing pointers — why returning one to a local is safe here, why there is no pointer arithmetic, and the four cases where a pointer is actually the right choice.

A pointer holds the address of a value. Go keeps them, drops the parts of C that made them dangerous, and adds a garbage collector, so the questions that remain are about design rather than safety.

Written against Go 1.22.

Declaring and using

x := 42
p := &x           // *int, the address of x

fmt.Println(p)    // 0xc000018030 — an address
fmt.Println(*p)   // 42 — dereference to read
*p = 21           // dereference to write
fmt.Println(x)    // 21 — x changed

& takes an address, * in a type means “pointer to”, and * before a value dereferences it. Those are three distinct uses of two symbols and reading them correctly is most of the difficulty.

The zero value is nil, and dereferencing nil panics:

var p *int
fmt.Println(p)     // <nil>
fmt.Println(*p)    // panic: invalid memory address or nil pointer dereference

new(T) allocates a zeroed T and returns a pointer to it:

p := new(int)     // *int pointing at 0
*p = 42

new is rarely used for structs, where &Note{...} both allocates and initialises.

Returning a pointer to a local is safe

This is the difference from C that matters most:

func newCounter() *int {
    n := 0
    return &n        // completely fine
}

In C that is a dangling pointer into a dead stack frame. In Go, escape analysis notices the address outlives the function and allocates n on the heap instead of the stack. You never declare where a value lives; the compiler decides.

$ go build -gcflags='-m' ./...
./main.go:4:2: moved to heap: n

That flag is how you find out. Useful when optimising, and not something to design around prematurely, the compiler is better at this than intuition.

No pointer arithmetic

p := &arr[0]
p++          // compile error
p = p + 1    // compile error

There is no way to step a pointer through memory. That removes buffer overruns as a language-level possibility, and it is why slices exist: a slice is the safe, bounds-checked version of “a pointer into an array plus a length”.

unsafe.Pointer can bypass this. It is for interoperating with C and for a handful of runtime tricks. It is exempt from Go’s compatibility promise, and it should be treated as out of scope for ordinary code.

Pointers as parameters

Go passes everything by value, so a function receives a copy. A pointer is how you let it modify the caller’s value:

func double(n int)   { n *= 2 }        // no effect on the caller
func doublePtr(n *int) { *n *= 2 }     // modifies the caller's variable

x := 21
double(x)
fmt.Println(x)    // 21
doublePtr(&x)
fmt.Println(x)    // 42

Note which types already contain a pointer internally: slices, maps, channels and function values. Passing a slice lets the callee modify its elements without a pointer, which is why *[]int is almost always a mistake, the exception being a function that must change the caller’s slice length, and returning the slice is clearer than that.

Pointer receivers

type Counter struct {
    n int
}

func (c Counter) Value() int { return c.n }   // reads a copy
func (c *Counter) Inc()      { c.n++ }        // mutates the original

c := Counter{}
c.Inc()                 // Go takes &c automatically for an addressable value
fmt.Println(c.Value())  // 1

Go inserts the address-of for you when the value is addressable, so c.Inc() works on a Counter rather than requiring (&c).Inc(). It cannot do that for a value that has no address, a map entry, or a function’s return value:

m := map[string]Counter{"a": {}}
m["a"].Inc()      // compile error: cannot call pointer method on m["a"]

Use map[string]*Counter when entries need mutating.

Pointer to pointer

x := 42
p := &x
pp := &p

fmt.Println(**pp)   // 42

Legal, occasionally necessary when a function must replace a caller’s pointer, reassigning the head of a linked list, for instance. In application code it is usually a sign that a return value would be clearer.

Comparing pointers

a, b := 1, 1
p1, p2, p3 := &a, &a, &b

fmt.Println(p1 == p2)   // true — same address
fmt.Println(p1 == p3)   // false — same value, different address
fmt.Println(p1 == nil)  // false

== on pointers compares addresses, not what they point at. To compare values, dereference both.

One genuine trap: a nil pointer stored in an interface makes the interface non-nil.

var p *Note = nil
var i interface{} = p

fmt.Println(p == nil)   // true
fmt.Println(i == nil)   // false — the interface holds a type and a nil value

This is the classic “my error is not nil but has no message” bug: a function declared to return error returning a nil *MyError produces a non-nil error. Return a literal nil, not a typed nil pointer.

When a pointer is the right choice

Four cases, and outside them prefer values:

Mutation. The function or method must change the caller’s value.

Large structs. Copying is proportional to size, so a struct of many fields, or one containing a large array, is cheaper to pass by pointer. Measure rather than guess; a few words copy faster than a pointer dereference resolves.

Optional values. nil distinguishes “not set” from “set to zero”, which a value type cannot. A *bool field says “true, false, or unspecified”, which is often exactly what a configuration or a JSON patch needs.

Shared identity. Several parts of the program refer to the same thing and must see each other’s changes.

Against them, values have real advantages: no nil check, no aliasing to reason about, better cache locality, and less garbage-collector pressure. Go’s own standard library passes small structs like time.Time by value throughout.

Frequently asked questions

What does & do, and what does * do?

&x takes the address of x. *T in a type means “pointer to T”. *p before a value dereferences the pointer to read or write what it points at.

Is it safe to return a pointer to a local variable?

Yes. Escape analysis moves the value to the heap when its address outlives the function, so there is no dangling pointer.

Why is there no pointer arithmetic?

Because it eliminates a whole class of memory bug. Slices provide the safe, bounds-checked equivalent of stepping through an array.

Do I need a pointer to modify a slice’s contents?

No. A slice header already contains a pointer, so a function can modify its elements. You only need to return the slice if its length changes.

Why does calling a pointer method on a map entry fail?

Map entries are not addressable, so Go cannot take their address implicitly. Store pointers as the map’s value type.

When should I pass a pointer instead of a value?

For mutation, for large structs, when nil is a meaningful state, or when identity must be shared. Otherwise pass the value.

Why is my error non-nil when I returned nil?

You likely returned a typed nil pointer. An interface holding a nil pointer is not equal to nil, because it still carries the type. Return a bare nil.

What does new() do?

Allocates a zeroed value of the given type and returns a pointer to it. For structs, &T{...} is preferred because it initialises at the same time.

Does comparing two pointers compare the values?

No, it compares addresses. Dereference both to compare values.

How do I find out whether a value escaped to the heap?

Build with -gcflags='-m'; the compiler reports each escape decision.

Where should I go next?

Structs covers the value types these pointers usually point at, and slices covers the built-in that wraps a pointer safely.