Pointers in Practice
A guide to using pointers in Go effectively - passing pointers to functions, trade-offs between pointers and values, and pointer comparison.
Pointers give you direct access to the memory that holds your data. Once you can declare them and dereference them, the next step is deciding where and how to use them in real code. This section covers three decisions you will face repeatedly: handing a pointer to a function instead of a value, choosing whether a variable should be a pointer or a value in the first place, and understanding what it means when you compare two pointers.
Passing Pointers to Functions
Go always passes arguments by value. When you pass a variable to a function, the function receives a copy. For an int or a small struct, that copy is cheap and harmless. For a large struct, or when the function needs to change the original data, copying is either expensive or wrong.
Passing a pointer changes what gets copied. Instead of copying the entire data, Go copies the address — an 8‑byte integer on a 64‑bit machine. The function now holds the address of the original variable. Through that address, it can read the original value and overwrite it.
func increment(n *int) {
*n++ // dereference, then increment the original
}
func main() {
x := 5
increment(&x)
fmt.Println(x) // 6
}
The call increment(&x) hands the function the address of x. Inside increment, *n reaches through that address to the integer stored there. The ++ applies to the original x, not a copy. Without the pointer, increment would receive a copy of 5, bump it to 6, and discard it when the function returned — x would remain 5.
Check for nil before dereferencing:
A pointer argument can be nil. If your function receives nil and dereferences it anyway, the program panics. Always decide whether nil is a valid input. If it is not, check and return early, or document that the caller must not pass nil. If nil is meaningful, handle it explicitly.
Functions that need to modify a struct field follow the same pattern.
type User struct {
Name string
Email string
}
func updateEmail(u *User, newEmail string) {
u.Email = newEmail
}
func main() {
user := User{Name: "Alice", Email: "alice@example.com"}
updateEmail(&user, "alice@newdomain.com")
fmt.Println(user.Email) // alice@newdomain.com
}
Even though u is a pointer, the field access u.Email works without explicit dereferencing. Go automatically dereferences the pointer to reach the struct. You could write (*u).Email, but the shorthand is idiomatic. This is one of the most common patterns in Go: functions that receive a pointer to a struct and modify it in place.
Output confirms the mutation:
If you run the example, you'll see the Email field change. The function did not return a new User; it altered the one that already existed. That is the direct result of passing a pointer.
A beginner‑friendly mental model: think of a pointer as a slip of paper with a home address written on it. Giving the slip to someone lets them visit the actual house and rearrange the furniture. Passing a value is like building an identical house somewhere else and telling them to rearrange that copy — the original house stays untouched.
A common mistake is forgetting the & at the call site.
updateEmail(user, "new@example.com") // compile error: cannot use user (type User) as type *User
The compiler catches this because updateEmail expects *User, not User. Still, new Gophers sometimes try to pass the value directly and are puzzled by the type mismatch. The fix is to write &user.
Pointers vs Values Trade-offs
Choosing between a pointer and a value touches performance, mutability, safety, and memory management. The decision is not one‑size‑fits‑all, but a few guidelines cover most cases.
Copy cost. Go copies values byte‑for‑byte. A User struct with three strings and a slice is larger than a Point struct with two integers. Passing a large struct by value duplicates all that data on the stack; passing a pointer copies only 8 bytes. The difference is measurable when the struct is large, or when it is passed through many function layers.
Mutability. A function that receives a value cannot affect the caller’s variable, no matter what it does to the copy. A function that receives a pointer can. Immutability by default is a powerful safety guarantee. If a piece of data should never change after creation, a value type enforces that rule mechanically.
Nil safety. A pointer can be nil; a value cannot. Every pointer you introduce is a potential nil‑dereference panic waiting to happen. Values eliminate that class of bug entirely. If a variable must always contain valid data, a value type is the safer choice.
Heap allocations and escape analysis. The compiler decides whether a variable lives on the stack or the heap. If a function returns a pointer to a local variable, that variable escapes to the heap because it must outlive the function’s stack frame. Heap allocations are more expensive than stack allocations and add work for the garbage collector. Value types that never escape stay on the stack and are cleaned up automatically when the function returns.
// This x escapes to the heap.
func newInt() *int {
x := 42
return &x
}
// This y stays on the stack.
func useInt() int {
y := 42
return y
}
Both functions are correct, but newInt triggers a heap allocation. The decision to return a pointer has a runtime cost, even if it is small. Avoid pointers solely for “performance” when a value works and the data is tiny.
Premature pointer use can backfire:
Beginners sometimes apply pointers everywhere under the assumption that “pointers are fast.” In Go, a pointer can push data onto the heap and add GC pressure, making the program slower than a plain value. Measure before you optimize, and default to values unless you have a clear reason to use a pointer.
The following table sums up when each choice tends to win:
| Situation | Prefer value | Prefer pointer |
|---|---|---|
| Data is small (a few ints, a small struct) | ✅ | |
| Data must be immutable after creation | ✅ | |
| The function needs to modify the caller’s data | ✅ | |
| Data is large (many fields, large slices) | ✅ | |
| The value represents a shared resource (config, connection) | ✅ | |
| Nil would be a meaningful state (optional field, missing data) | ✅ |
A struct like type Point struct{ X, Y int } is 16 bytes on a 64‑bit machine. Copying it is cheap, and there is no reason to involve a pointer unless a function must modify it. A struct that holds a large byte slice or several nested structs may justify a pointer solely to avoid copying.
Slices and maps are already reference types:
A slice value is a small header (pointer, length, capacity). Passing a slice copies the header, not the underlying array. The function can modify the array’s elements through the copy. The same applies to maps and channels. You rarely need a pointer to a slice or a map unless you want to change the slice header itself (e.g., append that may grow the backing array) or signal that the whole map may be replaced.
Pointer Comparison
Go allows you to compare two pointers with == and !=. The comparison answers one question: do these two pointers hold the same memory address? It does not compare the values stored at those addresses.
a := 10
b := 10
pa := &a
pb := &b
fmt.Println(pa == pb) // false — different addresses
fmt.Println(*pa == *pb) // true — both hold 10
pa and pb point to two distinct variables. Even though both variables contain 10, the addresses are different, so pa == pb is false. If you need to know whether the data is equal, dereference first and compare the values.
Two pointers that both point to the same variable are equal, regardless of how they were obtained.
x := 42
p1 := &x
p2 := &x
fmt.Println(p1 == p2) // true
A pointer can be compared to nil. This is the standard way to check whether a pointer has been initialized.
var p *int
fmt.Println(p == nil) // true
q := new(int)
fmt.Println(q == nil) // false
Comparing a nil pointer to a non‑nil pointer always yields false. Dereferencing a nil pointer in a comparison expression is a compile‑time error if the types do not match, but a runtime panic if the pointer is nil and you try to dereference it to compare values. Guard with a nil check first.
var p *int
// This panics:
// fmt.Println(*p == 5)
if p != nil && *p == 5 {
fmt.Println("matches")
}
Comparing pointers of different types is a compile error:
var pi *int; var ps *string; fmt.Println(pi == ps) will not compile. Go’s type system forbids comparing pointers to incompatible types, even if the raw addresses might look similar at runtime. The compiler protects you from a meaningless comparison.
Comparing pointers to structs works the same way: two struct pointers are equal only if they point to the exact same struct value in memory.
type Point struct{ X, Y int }
p1 := &Point{1, 2}
p2 := &Point{1, 2}
fmt.Println(p1 == p2) // false
fmt.Println(*p1 == *p2) // true — struct values are comparable field‑by‑field
When a function returns a pointer, the comparison may surprise you if you expect value equality. Two separate calls to a constructor that return pointers to identical data will produce unequal pointers.
func NewPoint(x, y int) *Point {
return &Point{x, y}
}
a := NewPoint(3, 4)
b := NewPoint(3, 4)
fmt.Println(a == b) // false
Each call allocates a new Point and returns its address. The addresses differ, so a == b is false. If your code needs to treat these as equal, compare the dereferenced values, or implement an Equal method.
Struct comparison includes all fields, even unexported ones:
*p1 == *p2 works only if the struct’s fields are all comparable. If a field is a slice, map, or function, the struct itself becomes incomparable, and == on the values will not compile. In those cases, write a custom comparison method.
Passing pointers, choosing between pointer and value, and comparing pointers are not isolated topics. They converge in every function signature you write. When you pass a pointer to a function, you have already decided that the data should be shared and possibly mutated. That decision implies you have weighed the trade‑offs against copying. And if you later compare two pointers obtained from different parts of the program, the result reflects those earlier design choices — whether you reused the same object or created independent copies.