4 min read
On this page

Pointers

Pointers in Go are simpler than C and more explicit than Java. There is no pointer arithmetic, no manual memory management, and no void pointers. Go pointers exist for one purpose: to let you share a value across function boundaries so the callee can read or modify the original.

The Basics: & and *

Two operators are all you need.

  • & takes the address of a variable (gives you a pointer)
  • * dereferences a pointer (gives you the value it points to)
x := 42
p := &x          // p is *int, pointing to x
fmt.Println(*p)  // 42 -- dereference to get the value
*p = 100         // Modify x through the pointer
fmt.Println(x)   // 100

The type *int means "pointer to int." The & operator creates a pointer. The * operator follows the pointer to the value.

func double(n *int) {
    *n *= 2
}

func main() {
    val := 21
    double(&val)
    fmt.Println(val)  // 42
}

Nil Pointers

A pointer with no assigned value is nil. Dereferencing a nil pointer causes a panic at runtime.

var p *int         // nil
fmt.Println(p)     // <nil>
// fmt.Println(*p) // PANIC: runtime error: invalid memory address

// Always check before dereferencing
if p != nil {
    fmt.Println(*p)
}

Nil pointers are useful for representing optional values. If a function returns *User, returning nil clearly means "no user found."

func findUser(id int) *User {
    user, ok := users[id]
    if !ok {
        return nil
    }
    return &user
}

func main() {
    u := findUser(42)
    if u == nil {
        fmt.Println("user not found")
        return
    }
    fmt.Println(u.Name)
}

When to Use Pointers

Mutation

The most common reason. If a function needs to modify its argument, it needs a pointer.

type Config struct {
    Host    string
    Port    int
    Verbose bool
}

func applyDefaults(cfg *Config) {
    if cfg.Host == "" {
        cfg.Host = "localhost"
    }
    if cfg.Port == 0 {
        cfg.Port = 8080
    }
}

func main() {
    cfg := Config{Verbose: true}
    applyDefaults(&cfg)
    fmt.Printf("%s:%d verbose=%v\n", cfg.Host, cfg.Port, cfg.Verbose)
}
localhost:8080 verbose=true

Without the pointer, applyDefaults would modify a copy and the caller's cfg would remain unchanged.

Optional Values

Pointers can represent "present or absent" since they can be nil.

type UpdateRequest struct {
    Name  *string  // nil means "don't update"
    Email *string  // nil means "don't update"
    Age   *int     // nil means "don't update"
}

func updateUser(id int, req UpdateRequest) error {
    user, err := getUser(id)
    if err != nil {
        return err
    }

    if req.Name != nil {
        user.Name = *req.Name
    }
    if req.Email != nil {
        user.Email = *req.Email
    }
    if req.Age != nil {
        user.Age = *req.Age
    }

    return saveUser(user)
}

This pattern is common for PATCH-style API endpoints where you need to distinguish between "set this field to empty" and "leave this field unchanged."

Large Structs

Passing a large struct by value copies all of it. A pointer avoids the copy.

type Report struct {
    Title    string
    Sections []Section
    Data     [10000]float64  // Large fixed-size array
}

// Bad: copies the entire Report (including the 80KB array) every call
func summarize(r Report) string {
    return fmt.Sprintf("%s: %d sections", r.Title, len(r.Sections))
}

// Good: passes an 8-byte pointer regardless of struct size
func summarize(r *Report) string {
    return fmt.Sprintf("%s: %d sections", r.Title, len(r.Sections))
}

When Not to Use Pointers

Small Structs, Read-Only

If a struct is small and you only need to read it, pass by value. The copy is cheap and you avoid nil-checking and aliasing concerns.

type Point struct {
    X, Y float64
}

// Value semantics: simple, safe, no nil to worry about
func distance(a, b Point) float64 {
    dx := a.X - b.X
    dy := a.Y - b.Y
    return math.Sqrt(dx*dx + dy*dy)
}

Slices, Maps & Channels

These types already contain internal pointers. Passing them by value does not copy the underlying data.

// No pointer needed -- the slice header is small (24 bytes)
// and the underlying array is shared
func sum(nums []int) int {
    total := 0
    for _, n := range nums {
        total += n
    }
    return total
}

A []int is a struct of three fields: a pointer, a length, and a capacity. Passing it by value copies the header but shares the underlying array. Same for maps and channels.

Strings

Strings in Go are immutable and internally represented as a pointer-length pair (16 bytes). Passing string by value is always cheap.

// No pointer needed for strings
func greet(name string) string {
    return "Hello, " + name
}

Pointer Receivers on Methods

Methods with pointer receivers can modify the struct and are the standard choice when the struct represents mutable state.

type Stack struct {
    items []int
}

func (s *Stack) Push(val int) {
    s.items = append(s.items, val)
}

func (s *Stack) Pop() (int, bool) {
    if len(s.items) == 0 {
        return 0, false
    }
    val := s.items[len(s.items)-1]
    s.items = s.items[:len(s.items)-1]
    return val, true
}

func (s *Stack) Len() int {
    return len(s.items)
}

func main() {
    var s Stack
    s.Push(1)
    s.Push(2)
    s.Push(3)

    for s.Len() > 0 {
        val, _ := s.Pop()
        fmt.Println(val)
    }
}
3
2
1

Go automatically takes the address when calling a pointer receiver method on an addressable value. That is why s.Push(1) works even though s is not a pointer -- the compiler rewrites it to (&s).Push(1).

No Pointer Arithmetic

Go deliberately omits pointer arithmetic. You cannot increment a pointer to walk through memory, and you cannot cast between arbitrary pointer types.

// This is not Go. None of this compiles.
// p++
// p += sizeof(int)
// q := (*float64)(p)

The unsafe package exists for low-level memory manipulation, but it is explicitly named to signal that you are leaving Go's safety guarantees. Standard application code should never need it.

Common Pitfalls

  • Nil pointer panics. The most common runtime crash in Go. When a function returns a pointer, check for nil before using it. This is especially important with map lookups and type assertions that return pointers.
  • Unnecessary pointers. Using *string or *int when the zero value would work fine. Only use pointer fields for genuinely optional values.
  • Pointer to loop variable. Before Go 1.22, the loop variable was reused across iterations. Taking its address inside a loop gave you a pointer to the last iteration's value.
// Bug in Go < 1.22
var ptrs []*int
for _, v := range []int{1, 2, 3} {
    ptrs = append(ptrs, &v)  // All point to the same variable
}
// ptrs[0], ptrs[1], ptrs[2] all point to 3

// Fixed in Go 1.22+ (loop variable is per-iteration)
// For older Go versions, capture the variable:
for _, v := range []int{1, 2, 3} {
    v := v  // Shadow the loop variable
    ptrs = append(ptrs, &v)
}
  • Confusing pointer equality with value equality. Two pointers are equal (==) only if they point to the same memory, not if the values they point to are the same.
  • Returning pointers to interface values. *io.Reader is rarely what you want. Interfaces already hold a pointer internally.
  • Premature optimization with pointers. Passing a 3-field struct by value costs nanoseconds. Do not add pointer indirection to save a trivial copy. Profile first.

Key Takeaways

  • & gives you a pointer. * follows a pointer. That is all the syntax there is.
  • Nil pointers are useful for optional values but will panic if dereferenced unchecked.
  • Use pointers for mutation, optional values, and large structs. Use values for small, read-only data.
  • Slices, maps, channels, and strings already contain internal pointers. Do not add another layer.
  • Go has no pointer arithmetic. The unsafe package exists but should not appear in application code.
  • Escape analysis moves heap-escaping values automatically. Trust the compiler and GC until profiling says otherwise.
  • Before Go 1.22, taking the address of a loop variable was a classic bug. In Go 1.22+, loop variables are per-iteration.