Pointers in Go: When to Use *T and Why
What a pointer actually is, why Go passes everything by value by default, and the concrete rule for when a function or method should take a pointer instead of a copy — with diagrams.
TL;DR
Go passes everything by value by default, so a function only sees a copy of its arguments unless you pass a pointer — a value holding the address of another value, letting the function reach back and modify the original.
What You'll Learn
- What a pointer actually stores, and what & and * do
- Why Go functions can't mutate their arguments unless a pointer is passed
- The concrete rule for choosing a pointer receiver vs. a value receiver on a method
- Why passing a large struct by pointer avoids a copy, and when that copy doesn't actually matter
- What a nil pointer is, and what happens when you dereference one
- Why Go has no pointer arithmetic, unlike C
Prerequisites
The problem
func double(n int) {
n = n * 2
}
func main() {
x := 5
double(x)
fmt.Println(x) // 5 — not 10
}double runs, multiplies something by two, and yet x is unchanged. This
isn't a bug — it's Go's most consistent rule: every function call copies
its arguments. n inside double is a brand-new int, initialized to a
copy of x's value; multiplying it does nothing to the original. Pointers
are how you opt out of that copy when you actually need to.
What a pointer is
A pointer is a value that holds the memory address of another value — nothing more exotic than that. Two operators do all the work:
&x— "give me the address ofx" — produces a pointer tox*p— "the value at the addressppoints to" — dereferences the pointer, either to read it or to assign through it
x := 5
p := &x // p is a *int, holding x's address
fmt.Println(*p) // 5 — dereference to read the value
*p = 10 // dereference to write through the pointer
fmt.Println(x) // 10 — x itself changedRendering diagram…
Fixing double with a pointer
func double(n *int) {
*n = *n * 2 // dereference, multiply, write back through the pointer
}
func main() {
x := 5
double(&x) // pass the address of x
fmt.Println(x) // 10
}double still receives a copy — but this time it's a copy of the address,
not the value. Both the caller's x and the copy inside double point at
the exact same memory, so writing through *n is visible to the caller the
instant the function runs.
Pointer receivers vs. value receivers
This same idea decides whether a method should take a pointer or a value receiver:
type Counter struct {
count int
}
func (c Counter) IncrementCopy() { // value receiver
c.count++ // mutates the copy, not the original
}
func (c *Counter) Increment() { // pointer receiver
c.count++ // mutates through the pointer — the original changes
}c := Counter{}
c.IncrementCopy()
fmt.Println(c.count) // 0 — the method only ever saw a copy
c.Increment()
fmt.Println(c.count) // 1 — the method mutated the real thing(Note c.Increment() works even though c isn't a pointer — Go
automatically takes &c for you when calling a pointer-receiver method on an
addressable value. You don't need to write (&c).Increment() yourself.)
The rule that covers almost every case:
- Use a pointer receiver when the method needs to mutate the receiver, or when the receiver is a large struct where copying it on every call would be wasteful.
- Use a value receiver when the type is small (an
int, a small struct, a slice/map — which are already cheap header copies) and the method has no reason to modify the caller's copy.
Mixing both on the same type is legal but confusing — if any method on a type needs a pointer receiver, it's conventional to make all of that type's methods pointer receivers, so callers don't have to remember which methods mutate and which don't.
Why this matters for large structs
Passing a struct by value copies every field, every time:
type Config struct {
Name string
Options [50]int // a sizable array field
}
func process(c Config) { /* ... */ } // copies the whole struct
func processFast(c *Config) { /* ... */ } // copies one pointer (8 bytes)For a small struct, the copy is cheap enough that this doesn't matter — don't reach for a pointer purely for performance until profiling says a copy is actually the bottleneck. For a large one, or one that's copied millions of times in a hot path, a pointer avoids the repeated copy of every field.
nil pointers
A pointer's zero value is nil — it holds no address at all. Dereferencing
one is one of the most common panics in Go:
var p *int // nil — no address
fmt.Println(*p) // panic: runtime error: invalid memory address or nil pointer dereferenceThis is also why a function returning *SomeType, error should always be
checked for an error before touching the returned pointer — a failed
lookup often returns (nil, err), and dereferencing that nil before
checking err is a direct route to this panic.
No pointer arithmetic
Coming from C, the next question is usually "can I do p++ to walk through
memory?" — no. Go pointers support exactly two operations: dereference
(*p) and comparison (p == nil, p1 == p2). There's no way to move a
pointer to point somewhere else by arithmetic, which is a deliberate safety
choice: it's what makes it impossible for a Go pointer to wander off into
memory it was never given a legitimate address to.
Common mistakes
- Expecting a plain value parameter to let a function mutate the caller's variable. It can't — pass a pointer if mutation is the goal.
- Dereferencing a pointer before checking whether it's
nil, especially one returned alongside anerror. - Reaching for a pointer receiver "for performance" on a small struct without measuring. The copy is usually irrelevant; the mutation semantics are the real reason to choose one.
- Mixing pointer and value receivers on the same type without a reason — pick one convention for that type and stay consistent.
Summary
Go copies every function argument by default; a pointer is how you pass an
address instead of a value, letting a function or method reach back and
modify something the caller can see. Choose a pointer receiver when a method
needs to mutate its receiver or the receiver is large; choose a value
receiver otherwise. A nil pointer holds no address, and dereferencing one
panics — check errors before touching a pointer a function returned
alongside one. With no pointer arithmetic, that's the entire, memory-safe
surface area pointers have in Go.
Key Takeaways
- Every Go function call copies its arguments; a pointer parameter copies an address, letting the function reach back and modify the original value
- Use a pointer receiver when a method needs to mutate the receiver, or when the value is large enough that copying it is wasteful
- Use a value receiver when the type is small (an int, a small struct) and the method shouldn't be able to mutate the caller's copy
- A nil pointer holds no address; dereferencing it (*p) panics with a nil pointer dereference
- Go has no pointer arithmetic — a pointer can only be dereferenced or compared, never incremented, which is what keeps it memory-safe
Go Fundamentals Series
Article 3 of 13
- 1.Go Modules and Packages: Setting Up and Structuring a Go Project
- 2.Slices, Arrays, and Maps in Go: How They Actually Work
- 3.Pointers in Go: When to Use *T and Why
- 4.Structs, Methods, and Composition in Go
- 5.Error Handling in Go: Custom Errors, Wrapping, and errors.Is/As
- 6.Defer, Panic, and Recover in Go
- 7.Go Interfaces: Design Small, Testable Components
- 8.Goroutines and Channels in Go: A Fresher-Friendly Guide
- 9.Understanding HTTP Servers in Go: From TCP Listen to Your First Handler
- 10.sync.WaitGroup, sync.Mutex, and the Race Detector in Go
- 11.Context in Go: Cancellation, Deadlines and Request Propagation
- 12.Building Background Workers in Go with Goroutines and Channels
- 13.Graceful Shutdown in Go HTTP Servers
funcRelated()[]Article
Slices, Arrays, and Maps in Go: How They Actually Work
Why a slice is a small header pointing at a backing array, what append actually does when it grows, why slicing can alias memory you didn't mean to share, and how Go's maps behave — with diagrams.
Structs, Methods, and Composition in Go
How to define your own types with structs, attach behavior with methods, and build up functionality through embedding instead of inheritance — with diagrams and real examples.
Defer, Panic, and Recover in Go
How defer actually schedules a call, what panic does to the call stack, why recover only works inside a deferred function, and the real-world pattern of recovering from panics in HTTP middleware — with diagrams.