Go Pointers: Memory Addresses and Dereferencing

Go is a pass-by-value language. Every time you pass a variable to a function or assign it to another variable, Go creates a copy. For integers and booleans, this is trivial. But for large structs or...

Key Insights

  • Pointers in Go store memory addresses rather than values, enabling functions to modify caller data and avoid expensive copies of large structs
  • The & operator gets a variable’s address while * dereferences a pointer to access its value—mixing these up is the most common pointer mistake
  • Unlike C, Go pointers are safe by design with no pointer arithmetic, automatic garbage collection, and compile-time nil safety checks in many cases

Understanding Pointers and Memory Addresses

Go is a pass-by-value language. Every time you pass a variable to a function or assign it to another variable, Go creates a copy. For integers and booleans, this is trivial. But for large structs or when you need a function to modify the original data, copying becomes problematic.

Pointers solve this by storing memory addresses instead of values. When you pass a pointer, you’re passing a small address (typically 8 bytes on 64-bit systems) rather than the entire data structure.

package main

import "fmt"

func main() {
    x := 42
    fmt.Printf("Value: %d\n", x)
    fmt.Printf("Address: %p\n", &x)
    
    var p *int = &x
    fmt.Printf("Pointer value (address): %p\n", p)
    fmt.Printf("Pointer's own address: %p\n", &p)
}

This outputs something like:

Value: 42
Address: 0xc0000b4008
Pointer value (address): 0xc0000b4008
Pointer's own address: 0xc0000b4010

The & operator (address-of) gives you the memory location where x lives. The pointer p stores that address. Notice that p itself also has an address—pointers are variables too.

Declaring and Initializing Pointers

Go provides three main ways to work with pointers, each suited for different scenarios.

// Method 1: Declare pointer type, assign address of existing variable
var x int = 100
var p1 *int = &x

// Method 2: Short declaration with address-of operator
y := 200
p2 := &y

// Method 3: Allocate new memory with new()
p3 := new(int)
*p3 = 300

// Zero value of a pointer is nil
var p4 *int
fmt.Println(p4) // <nil>

The new() function allocates memory for a value, initializes it to the zero value of that type, and returns a pointer to it. This is useful when you need a pointer but don’t have an existing variable to reference.

Understanding nil is crucial. A nil pointer doesn’t point anywhere. Attempting to dereference it causes a runtime panic—one of the few ways to crash a Go program.

Dereferencing: Accessing Values Through Pointers

The * operator serves double duty in Go. In type declarations (*int), it denotes a pointer type. In expressions, it dereferences a pointer, accessing the value at that memory address.

func main() {
    count := 5
    ptr := &count
    
    // Read through pointer
    fmt.Println(*ptr) // 5
    
    // Write through pointer
    *ptr = 10
    fmt.Println(count) // 10 - original variable changed!
    
    // The pointer itself can be reassigned
    other := 20
    ptr = &other
    fmt.Println(*ptr) // 20
}

This demonstrates pointers’ power: modifying *ptr modifies count because they reference the same memory location. This isn’t magic or action-at-a-distance—you’re directly manipulating the memory where count is stored.

Pointers as Function Parameters

This is where pointers become indispensable. Without pointers, functions operate on copies and cannot modify the caller’s data.

type User struct {
    Name  string
    Email string
    Age   int
}

// Pass by value - modifications don't affect caller
func updateAgeValue(u User, newAge int) {
    u.Age = newAge
}

// Pass by pointer - modifications affect caller
func updateAgePointer(u *User, newAge int) {
    u.Age = newAge
}

func main() {
    user := User{Name: "Alice", Email: "alice@example.com", Age: 30}
    
    updateAgeValue(user, 31)
    fmt.Println(user.Age) // Still 30
    
    updateAgePointer(&user, 31)
    fmt.Println(user.Age) // Now 31
}

Beyond modification, pointers improve performance with large structs. Copying a 1KB struct on every function call wastes CPU cycles and stack space. Passing a pointer copies only 8 bytes regardless of struct size.

type LargeConfig struct {
    Settings [1000]string
    Metadata map[string]interface{}
    // ... many more fields
}

// Inefficient: copies entire struct
func ProcessConfigValue(cfg LargeConfig) {
    // ...
}

// Efficient: copies only pointer
func ProcessConfigPointer(cfg *LargeConfig) {
    // ...
}

Go’s syntax sugar lets you access struct fields through pointers without explicit dereferencing: cfg.Settings works whether cfg is a value or pointer. The compiler handles it.

Common Pointer Patterns and Pitfalls

The most dangerous pitfall is dereferencing nil pointers:

var p *int
// *p = 5  // panic: runtime error: invalid memory address

// Always check for nil before dereferencing
if p != nil {
    *p = 5
}

// Or initialize before use
p = new(int)
*p = 5

Pointer receivers on methods are idiomatic Go for types that should be modified:

type Counter struct {
    value int
}

// Pointer receiver - modifies the counter
func (c *Counter) Increment() {
    c.value++
}

// Value receiver - works on a copy
func (c Counter) Value() int {
    return c.value
}

func main() {
    counter := Counter{}
    counter.Increment()
    counter.Increment()
    fmt.Println(counter.Value()) // 2
}

Use pointer receivers when:

  • The method needs to modify the receiver
  • The struct is large (avoid copying)
  • Consistency matters (if some methods use pointer receivers, use them for all methods on that type)

Use value receivers when:

  • The type is small (primitive types, small structs)
  • The method doesn’t modify state
  • The type should be immutable

Pointers to pointers are legal but rarely needed:

func main() {
    x := 42
    p := &x
    pp := &p
    
    fmt.Println(**pp) // 42
    **pp = 100
    fmt.Println(x) // 100
}

This appears in scenarios like modifying a pointer itself within a function, though such designs usually indicate a code smell.

Practical Applications

Pointers are essential for dynamic data structures. Here’s a simple linked list node:

type Node struct {
    Value int
    Next  *Node
}

func (n *Node) Append(value int) {
    current := n
    for current.Next != nil {
        current = current.Next
    }
    current.Next = &Node{Value: value}
}

func (n *Node) Print() {
    current := n
    for current != nil {
        fmt.Printf("%d -> ", current.Value)
        current = current.Next
    }
    fmt.Println("nil")
}

func main() {
    head := &Node{Value: 1}
    head.Append(2)
    head.Append(3)
    head.Print() // 1 -> 2 -> 3 -> nil
}

Without pointers, you couldn’t create the self-referential structure that makes linked lists possible.

Another practical pattern is optional values. Before Go 1.18’s generics, pointer-to-type was the standard way to represent “value or nothing”:

type Config struct {
    Timeout *int // nil means use default
    MaxRetries *int
}

func NewConfig() *Config {
    return &Config{}
}

func (c *Config) GetTimeout() int {
    if c.Timeout != nil {
        return *c.Timeout
    }
    return 30 // default
}

func main() {
    cfg := NewConfig()
    fmt.Println(cfg.GetTimeout()) // 30
    
    timeout := 60
    cfg.Timeout = &timeout
    fmt.Println(cfg.GetTimeout()) // 60
}

This pattern distinguishes between “not set” (nil) and “set to zero value” (pointer to 0).

Best Practices and Guidelines

Start with values, use pointers when necessary. Go’s compiler is smart about escape analysis—it automatically moves variables to the heap when they need to outlive their stack frame. You don’t need to think about stack vs heap allocation in most cases.

Don’t return pointers to local variables from functions just because you can. The compiler handles this safely, but it forces heap allocation. Return values directly when possible.

// Unnecessary heap allocation
func NewCounter() *int {
    x := 0
    return &x
}

// Better: return value directly
func NewCounter() int {
    return 0
}

Use pointers for slices and maps sparingly. These types are already reference types—they contain pointers internally. Passing them by value is usually fine.

Document whether functions modify their pointer parameters. This isn’t enforced by the compiler, so clear function names and comments matter:

// UpdateUser modifies the user in place
func UpdateUser(u *User) error {
    // ...
}

// GetUserCopy returns a copy, original unchanged
func GetUserCopy(u User) User {
    // ...
}

Pointers are fundamental to Go, but they’re simpler than in C or C++. No pointer arithmetic, no manual memory management, no dangling pointers. Master the & and * operators, understand when to use pointer receivers, and you’ll write idiomatic Go that’s both efficient and safe.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.