When to Use a Pointer to a Struct vs a Value in Go

Use pointers for large or mutable structs to avoid copying, and values for small or immutable data to ensure safety.

The photocopy versus the index card

You write a function that takes a configuration struct, tweaks a setting, and returns. Back in the caller, the setting hasn't changed. You stare at the code, convinced you modified the right field. The issue isn't a bug in your logic. It's Go's default behavior. Go copies data by value. When you pass a struct to a function, the compiler makes a full photocopy of every field. The function edits the photocopy. The original stays untouched.

Think of a value like a printed document. Handing it to a colleague gives them their own copy. They can highlight it, tear pages, or lose it entirely without affecting your original. A pointer is an index card with a room number. Handing the card gives someone access to the exact same room. Changes they make inside are immediately visible to everyone else holding a card. Go gives you the photocopy by default. You have to explicitly ask for the index card by using the & operator.

This design choice isn't arbitrary. Copying by value makes programs predictable. You never have to guess whether a function will silently mutate your data. The trade-off is that you must consciously opt into sharing when you actually need it. Understanding when to reach for a pointer versus a value is one of the first mental models that separates Go beginners from Go developers.

Seeing the copy in action

Here's the simplest way to observe the difference. A small struct passed by value gets copied. A pointer passes the memory address.

type Config struct {
    Host string
    Port int
}

func modifyValue(c Config) {
    // Creates a fresh copy on the stack. Changes here vanish when the function returns.
    c.Port = 8080
}

func modifyPointer(c *Config) {
    // Follows the address to the original memory location. Changes persist across the call boundary.
    c.Port = 8080
}

func main() {
    cfg := Config{Host: "localhost", Port: 3000}
    modifyValue(cfg)
    // Port remains 3000. The function only touched its own stack copy.
    
    modifyPointer(&cfg)
    // Port is now 8080. The function wrote directly to the original allocation.
}

When modifyValue runs, the compiler allocates space on the stack for a fresh copy of cfg. The Port field changes to 8080 inside that function, but the stack frame is discarded when the function returns. modifyPointer receives a 64-bit address. It follows that address to the original memory location and writes directly to it. No copy happens. The change persists.

The compiler handles the address-of operator & by checking that the value has a stable memory location. You cannot take the address of a literal or a temporary expression that disappears at the end of the line. If you try, the compiler rejects the program with cannot take the address of temporary value. This rule prevents dangling pointers from ever existing in valid Go code.

Values isolate. Pointers share. Pick the behavior you actually want.

When size changes the math

The photocopy analogy breaks down when the document is a thousand pages long. Copying a large struct across function boundaries burns CPU cycles and memory bandwidth. Go's runtime is fast, but it won't magically optimize away a megabyte copy just because you didn't notice it. This is where pointers save you from silent performance tax.

Here's a realistic scenario. A metrics collector accumulates request data. It contains a large embedded buffer and several counters. Passing it by value to a logging helper would copy the entire buffer every time.

type MetricsCollector struct {
    // Large embedded buffer that holds raw request payloads for batch processing.
    // Copying this across function boundaries wastes memory bandwidth.
    Payloads [1024 * 64]byte
    
    // Counters that track request volume and error rates.
    RequestCount int64
    ErrorCount   int64
}

func logSnapshot(m *MetricsCollector) {
    // Reads the counters without copying the massive Payloads array.
    // The pointer lets us inspect state cheaply.
    fmt.Printf("Requests: %d, Errors: %d\n", m.RequestCount, m.ErrorCount)
}

func main() {
    collector := MetricsCollector{}
    // Simulate work by bumping the counter.
    collector.RequestCount++
    
    // Passing the pointer avoids copying 64KB of payload buffer.
    logSnapshot(&collector)
}

The compiler sees the large struct and copies it if you pass it by value. If you pass it ten times, you copy it ten times. Switching to a pointer means you copy a single machine word. The trade-off is that you now share state. Every function that receives the pointer can mutate the underlying data. That's fine for a metrics collector. It's dangerous for a configuration snapshot you meant to keep immutable.

Go's escape analysis quietly changes where this data lives. When you pass a value, it almost always stays on the stack. When you pass a pointer, the compiler checks whether the pointer might outlive the current function. If it does, the compiler moves the allocation to the heap. The heap is managed by the garbage collector, which adds latency and memory fragmentation. Pointers aren't free. They trade copy cost for allocation and GC cost.

Measure before you optimize. A pointer isn't faster if the struct fits in a CPU cache line.

The hidden costs of sharing

Pointers introduce two classic failure modes. The first is the nil pointer. If you declare a pointer but never initialize it, or if a function returns a nil pointer that you blindly dereference, the program crashes with panic: runtime error: invalid memory address or nil pointer dereference. Go doesn't protect you from nil. You have to check or guarantee initialization.

The second failure mode is accidental mutation. You pass a pointer to a helper function, expecting it to read a field. The helper function has a bug and overwrites it. Because the memory is shared, the corruption spreads instantly. Values prevent this by design. A copy is a copy.

You'll also run into subtle bugs when mixing pointers and values in method receivers. If you define a method on a value receiver but call it on a pointer, Go automatically dereferences the pointer, calls the method, and discards the result. The reverse doesn't work. If you define a method on a pointer receiver but call it on a value, the compiler rejects the program with cannot call pointer method on value type. This rule exists because a value receiver gets a copy. Mutating that copy inside a pointer method would be meaningless. The compiler forces you to be explicit about ownership semantics.

Go developers follow a simple rule for method receivers: match the pointer semantics to the mutation intent. If a method changes the struct, use a pointer receiver (s *Server) Start(). If it only reads, use a value receiver (s Server) String(). The receiver name is almost always one or two letters that match the type, like (c *Config) or (p Point). This keeps signatures readable and signals intent at a glance. You will also see the community mantra "accept interfaces, return structs" everywhere. Functions take flexible contracts but hand back concrete, copyable data. It reduces coupling and keeps pointer usage intentional.

Nil pointers crash. Shared state corrupts. Guard your boundaries.

The decision matrix

Use a pointer when you need to mutate the original struct from inside a function or method. Use a pointer when the struct is large enough that copying it degrades performance or increases memory pressure. Use a pointer when you need to represent the absence of a value, since a pointer can be nil while a value cannot. Use a pointer when you are building a tree, graph, or linked list where nodes reference each other. Use a pointer when you are interfacing with C code through cgo, since foreign libraries expect memory addresses. Use a value when the struct is small, typically under a few dozen bytes, and fits comfortably in a CPU register or cache line. Use a value when you want to guarantee immutability and prevent accidental side effects across function boundaries. Use a value when you are returning data from a function, following the community convention of accepting interfaces and returning structs. Use a value when you are storing configuration snapshots or request contexts that should remain stable throughout a transaction.

Don't fight the type system. Wrap the value or change the design.

Where to go next