Passing Pointers to Functions

Learn how to pass pointers as function arguments in Go to modify variables directly and efficiently avoid copying large structs.

When a function needs to change a value that the caller owns — an integer, a struct, a counter — it cannot do that with a plain copy. In Go, every argument is passed by value, meaning the function always receives a duplicate. Passing a pointer gives the function the original location in memory instead of a duplicate, so any change made through that pointer is visible to the caller.

This page covers the mechanics of pointer parameters, the two motivations that make them worth reaching for, common errors that surprise new Gophers, and situations where using a pointer is the wrong choice.

Why Pass Pointers to Functions

Two distinct needs push Go code toward pointer parameters: the ability to mutate a caller’s data, and the performance savings from not copying large values.

Mutating Caller‑Visible State

Imagine a function that should reset an integer counter or update a field inside a struct. If you pass the value, the function works on a copy. The caller’s variable stays unchanged. To alter the original, you must tell the function where the original lives — you pass its address.

This is not a language quirk; it is the only mechanism Go provides for a function to produce a side effect on a variable that belongs to the calling scope. Without pointers, every function would be a pure data-in-data-out pipeline, which is often impractical.

Avoiding Large Struct Copies

When a struct is large — perhaps it holds a buffer, a slice of configuration, or nested data — copying it each time it is handed to a function wastes both CPU and memory. Passing a pointer sends an 8‑byte address (on 64‑bit systems) regardless of the struct’s size. The function reads and writes through that address, never duplicating the full structure.

Pass‑by‑value with a twist:

Go is strictly pass‑by‑value. When you pass a pointer, the pointer itself is copied, but the copy holds the same address. Dereferencing either copy reaches the same underlying data. This is what makes mutation possible while the language remains conceptually clean.

How Pointer Parameters Work

A function that accepts a pointer declares the parameter with a * prefix:

func reset(n *int) {
    *n = 0
}

Inside reset, n is a copy of the address. To touch the actual integer, you dereference: *n. Assigning to *n writes through the address into the caller’s variable.

From the caller’s side, you supply the address with the & operator:

x := 42
reset(&x)
fmt.Println(x) // 0

The type *int is concrete: reset accepts only pointers to int. You cannot pass a plain int to it — the compiler will refuse with a type mismatch.

A helpful mental model: think of a pointer parameter as a remote control for a television. The remote is small and cheap to hand around; pressing a button changes the TV, not the remote itself. Copying the remote gives you another remote still pointing at the same TV.

Modifying an Integer Through a Pointer

A minimal, runnable example shows the effect:

package main
import "fmt"
func double(n *int) {
    *n = *n * 2
}
func main() {
    val := 10
    fmt.Println("before:", val)
    double(&val)
    fmt.Println("after:", val)
}

The output is before: 10 then after: 20. The multiplication inside double directly updates the val variable in main. If the parameter had been n int instead of n *int, the original val would still be 10 after the call.

Confirm mutation:

Run this code. If you see 20 printed after the function call, the pointer mechanism is working correctly. The function changed the caller’s copy, not a local duplicate.

The pattern scales naturally to any writable value — strings, floats, custom structs — as long as the parameter type matches the address being passed.

Modifying a Struct’s Fields

Passing a pointer to a struct lets a function update fields without returning the entire struct.

package main
import "fmt"
type Config struct {
    Port    int
    Debug   bool
}
func enableDebug(cfg *Config) {
    cfg.Debug = true
    cfg.Port = 8080
}
func main() {
    c := Config{Port: 3000, Debug: false}
    enableDebug(&c)
    fmt.Printf("%+v\n", c) // {Port:8080 Debug:true}
}

Go simplifies struct field access through a pointer: cfg.Debug is syntactic sugar for (*cfg).Debug. You rarely need to write the explicit dereference when reading or writing fields.

This is one of the most common real‑world uses of pointer parameters — a configuration object or a request struct that flows through many setup functions, each tweaking a few fields.

Two Ways to Supply the Address

Go accepts the address in either of two equivalent forms. Which one you pick is a matter of readability and whether you already have a pointer variable.

Using & directly in the call:

func reset(n *int) { *n = 0 }
func main() {
    x := 5
    reset(&x) // pass address of x
    fmt.Println(x) // 0
}

Creating a pointer variable first:

func reset(n *int) { *n = 0 }
func main() {
    x := 5
    p := &x
    reset(p)   // pass pointer variable
    fmt.Println(x) // 0
}

Both approaches produce identical behaviour. The second is useful when you need to hold the pointer for multiple calls or for nil‑checks later in the same function.

Common Mistakes and How to Avoid Them

Forgetting to Dereference

A pointer variable and the value it points to are separate. Assigning to the pointer variable itself redirects the local copy of the address, which has no effect outside the function.

func broken(n *int) {
    n = new(int) // only changes the local n; caller unaffected
    *n = 10
}

The caller’s original pointer (or the address it supplied) still points to the old memory. The fix is to dereference n to write through it: *n = 10.

Assigning to the pointer vs. assigning through it:

n = something changes where the local copy of the pointer points. *n = something writes into the memory the pointer already references. Confusing the two is the most frequent pointer mistake in function bodies.

Passing a Nil Pointer

If the caller passes a nil pointer, any dereference inside the function panics at runtime. The compiler does not catch this.

func inc(n *int) {
    *n++ // panics if n is nil
}

A safe function should check for nil when the caller legitimately might not have a value to provide:

func inc(n *int) {
    if n == nil {
        return
    }
    *n++
}

Nil pointer dereference panics:

Dereferencing a nil pointer causes a run‑time panic that stops the entire goroutine. When a function accepts a pointer, always decide whether nil is a valid input. If it is, guard with an if n == nil check. If it is not, document that nil will panic, but know that callers may still accidentally pass it.

Misunderstanding Reference Types

Slices, maps, and channels are already reference types — their internal implementation contains a pointer to an underlying data structure. Passing a []int by value copies the slice header (length, capacity, pointer), but the underlying array is shared. So modifying elements of the slice inside a function does affect the caller’s data without needing a pointer to the slice.

func fill(s []int) {
    for i := range s {
        s[i] = i
    }
}
func main() {
    nums := make([]int, 4)
    fill(nums)
    fmt.Println(nums) // [0 1 2 3]
}

A *[]int is almost never the right tool — it would let you replace the entire slice header, but that is rarely what you want. The same reasoning applies to maps and channels: passing them by value is sufficient for mutation. Reserve pointer parameters for values that are truly copied by value, like integers, structs, and arrays.

When Not to Use Pointers

Pointers add indirection and the possibility of nil; they are not free at the language level. In several situations, passing by value is clearer and safer.

Small Immutable Structs

A struct like type Point struct{ X, Y float64 } is 16 bytes on a 64‑bit machine. Copying it costs roughly as much as copying a pointer and dereferencing it later. Passing by value guarantees the function cannot unexpectedly alter the caller’s Point, which simplifies reasoning in concurrent code.

Functions That Must Not Mutate

When a function is intended only to compute and return a result — a pure function — passing by value signals that intent clearly. Accidentally mutating a value through a pointer in a function that is supposed to be a read‑only operation is a subtle source of bugs.

Concurrency Without Synchronisation

A pointer handed to multiple goroutines gives each of them write access to the same memory. Without explicit synchronisation (mutexes, channels), this causes data races. Value semantics avoid the problem because each goroutine works on its own copy. Not every function that receives a pointer needs synchronisation, but when you pass a pointer into a concurrent context, you must think about who else can write to that memory.

Summary

Passing a pointer to a function is the Go idiom for letting a function modify a caller’s data and for bypassing expensive copies of large values. The key mechanism — copying the address while sharing the target — relies on the language’s consistent pass‑by‑value design. Getting comfortable with the * and & operators, and knowing when a nil pointer is acceptable, prevents the majority of pointer‑related bugs.