Pointers: Addresses Without the Footguns

What a pointer is in Go, the & and * operators, why there's no pointer arithmetic, new(T) versus &T{}, the nil-dereference panic, automatic struct dereference, and the one question that actually decides when to use one. Compiled and run against Go 1.26.5.

A pointer is a value that holds the address of another value. That’s the whole idea. Instead of carrying a copy of some data around, you carry a small number that says “the data lives there,” and anyone holding that number can go read or write the original.

If you’ve met pointers in C, put half of what you know aside. Go has pointers, and they do the fundamental job you’d expect, but they deliberately drop the sharp parts: there is no pointer arithmetic, the garbage collector keeps track of what’s still reachable, and a pointer that points nowhere is caught at runtime instead of silently corrupting memory. This chapter is about what remains, which is exactly the useful part.

What a pointer is, and the two operators

Every value your program creates lives somewhere in memory, and that somewhere has an address. Two operators let you work with addresses:

  • &x — the address-of operator. It gives you a pointer to x.
  • *p — the dereference operator. It gives you the value that p points at, and you can read or assign through it.

The type of a pointer to an int is written *int, a pointer to a Book is *Book, and so on. The * in a type name and the * in an expression are the same symbol doing related jobs: *int is “pointer to int,” and *p is “the int that pointer points at.”

package main

import "fmt"

func main() {
	x := 42
	p := &x // p is *int, holds the address of x

	fmt.Println("x       =", x)
	fmt.Println("p       =", p != nil) // an address prints as 0x..., so just show it's set
	fmt.Println("*p      =", *p)       // dereference: read through the pointer

	*p = 100 // write through the pointer
	fmt.Println("x after =", x)

	// new(T) allocates a zeroed T and returns a *T
	q := new(int)
	fmt.Println("*q      =", *q) // zero value
	*q = 7
	fmt.Println("*q      =", *q)
}
x       = 42
p       = true
*p      = 42
x after = 100
*q      = 0
*q      = 7

The line that matters is *p = 100. We never touched x by name, yet x changed, because p pointed at it and we wrote through it. That is the entire reason pointers exist: shared access to one piece of data.

No pointer arithmetic, on purpose

In C you can take a pointer and add to it to walk through memory, p+1, p+2, stepping past the end of an array into whatever happens to be next. That flexibility is also the source of a large fraction of security bugs ever written. Go removes it. You cannot add to a pointer, subtract two pointers, or cast an integer into one in ordinary code. A *int points at exactly one int, forever, or it’s nil.

Two things depend on this restriction. The garbage collector can only reclaim memory safely if it always knows which pointers are live and what they point at; arbitrary arithmetic would make that impossible. And memory safety — no buffer overruns by pointer walking — falls out for free. When you need to iterate over a block of values you use a slice, which carries its own bounds and is checked on every access. The escape hatch exists (unsafe.Pointer), but it’s a specialist tool that shouts its own name, not something you reach for by accident.

new(T) versus &T{}

There are two ways to get a pointer to freshly allocated storage, and both appear above and below.

new(T) allocates a zeroed T and returns a *T. You saw new(int) produce a *int pointing at 0. It works for any type but gives you no way to set fields, so in practice you mostly see it for basic types.

&T{} takes the address of a composite literal, and it’s what Go programmers actually write for structs, because you can fill fields in the same breath:

b := &Book{Title: "The Go Programming Language", Price: 39.99}

&Book{} and new(Book) produce the same thing, a *Book to a zeroed struct, but only the literal form lets you initialize. Reach for &T{...} almost always; new is a niche convenience.

The nil pointer, and the panic it causes

A pointer’s zero value is nil: it points at nothing. Since Go has no uninitialized memory, a *int you declare without assigning is nil, not garbage. Reading through a nil pointer is a bug the runtime catches, and it catches it by panicking — stopping the goroutine with a diagnostic rather than reading address zero.

package main

import "fmt"

func main() {
	var p *int // declared but not pointed anywhere: the zero value is nil
	fmt.Println("p == nil:", p == nil)
	fmt.Println("about to dereference...")
	fmt.Println(*p) // panic: runtime error: invalid memory address or nil pointer dereference
}
p == nil: true
about to dereference...
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0xb38c7a5]

goroutine 1 [running]:
main.main()
	/Users/.../main.go:9 +0xa5
exit status 2

That’s a real panic, captured from the run. The lesson is not “pointers are dangerous” but “a nil dereference fails loudly and immediately,” with a stack trace pointing at the exact line, instead of corrupting something three functions away. When a function can return a pointer that might be nil, check it before you dereference — the same discipline you’ll apply to errors in the next chapter.

Pointers to structs: automatic dereference

Here’s a convenience that makes pointers pleasant to use in Go. When you have a pointer to a struct, you access its fields with . directly. You do not write (*b).Field; the compiler inserts the dereference for you.

package main

import "fmt"

type Book struct {
	Title string
	Price float64
}

func main() {
	// &T{} is the idiomatic way to make a pointer to a struct literal.
	b := &Book{Title: "The Go Programming Language", Price: 39.99}

	// Automatic dereference: b.Title, not (*b).Title.
	fmt.Println(b.Title)
	fmt.Printf("$%.2f\n", b.Price)

	b.Price = 34.99 // also auto-dereferenced through the pointer
	fmt.Printf("$%.2f\n", b.Price)

	// The explicit form is legal but nobody writes it:
	fmt.Println((*b).Title)
}
The Go Programming Language
$39.99
$34.99
The Go Programming Language

b.Title and (*b).Title compile to the same thing; the first is the one you write. This is why working with *SomeStruct feels almost identical to working with the struct itself, and it’s part of why passing a pointer to a struct is so common that it barely registers as a decision.

When to use a pointer

The whole question reduces to one word: mutation. If a function needs to change the caller’s value, the caller must hand it a pointer, because Go passes everything by value — every argument is a copy. A copy of a struct is a whole new struct, and writing to it changes nothing the caller can see.

package main

import "fmt"

type Account struct {
	Balance int
}

// byValue receives a COPY of the struct; changes are lost.
func byValue(a Account) {
	a.Balance += 100
}

// byPointer receives the address; changes reach the caller's value.
func byPointer(a *Account) {
	a.Balance += 100
}

func main() {
	acc := Account{Balance: 0}

	byValue(acc)
	fmt.Println("after byValue:  ", acc.Balance)

	byPointer(&acc)
	fmt.Println("after byPointer:", acc.Balance)
}
after byValue:   0
after byPointer: 100

byValue incremented its own private copy and threw it away at the return. byPointer was handed the address of the real account and changed the real account. That contrast is the single most important thing to internalize about pointers in Go, and it’s why a method that modifies its receiver takes a pointer receiver (a topic we treat with methods).

Mutation is the main reason, but two others count:

  • Large structs. Passing a big struct by value copies every byte on every call. Passing a pointer copies one address. When a struct is large or copied in a hot path, a pointer avoids the copy. Don’t over-apply this to small structs, where a copy is cheap and often faster than the indirection.
  • Optional or nullable values. A *T can be nil to mean “absent,” which a plain T (with its zero value) cannot always express unambiguously. A *int distinguishes “no value” from “the value zero.”

A word on where pointers live

You may be wondering whether taking &x forces x onto the heap, the way it would in C where a local’s address can’t outlive the stack frame. In Go it doesn’t have to. The compiler runs escape analysis: it works out whether a value’s address escapes the function, and if it does, the value is allocated on the heap and the garbage collector manages it; if it doesn’t, it stays on the stack and is freed for free when the function returns. You can return &x from a function safely, and Go does the right thing.

The practical upshot is that you rarely think about stack versus heap at all. You take an address when you need shared or mutable access, and the compiler figures out where the storage belongs. The deep treatment — reading the compiler’s -gcflags=-m output, tuning allocations — lives in the Go in Production series; here it’s enough to know the machinery exists and that it removes a whole category of decisions you might expect to have to make.

Final thoughts

A pointer holds an address; & makes one and * reads through it. Go keeps the useful half of pointers and drops the dangerous half: no arithmetic, a garbage collector that tracks reachability, and a nil dereference that panics loudly instead of corrupting memory. Use &T{...} to build pointers to structs, lean on automatic dereference so p.Field just works, and reach for a pointer when you need to mutate a caller’s value, avoid copying something large, or express “maybe absent.” Everything else about where the memory actually sits, the compiler handles.

Next: interfaces, the central Go idea — how a type can satisfy a contract without ever saying so.

Comments