DEV Community

Cover image for Understanding Pointers in Go: A Practical Guide
Md Abu Musa
Md Abu Musa

Posted on

Understanding Pointers in Go: A Practical Guide

A hands-on look at how pointers work in Go, when to use them, and how they behave with arrays.

Introduction

Pointers have a reputation for being tricky, mostly thanks to languages where they come with pointer arithmetic, manual memory management, and a long list of ways to crash your program. Go takes a much calmer approach. Pointers in Go are simple, safe, and explicit, and once you understand them you will write faster, more predictable code.

In this post we will cover:

  • What a pointer is and how to use & and *
  • Pass-by-value vs. pass-by-pointer
  • Pointers with structs
  • Pointers with arrays (with full examples)
  • Common pitfalls and when to use pointers

What Is a Pointer?

A variable stores a value. A pointer stores the memory address of a value.

Think of a house. The house itself is the value. The street address written on a piece of paper is the pointer. If you hand someone a photocopy of the house (a copy of the value), they can repaint their copy all day and your house stays the same. If you hand them the address (a pointer), they can walk over and repaint your actual house.

That single idea explains almost everything about pointers in Go.

The Two Operators: & and *

Go uses just two operators for pointers:

  • & gives you the address of a variable.
  • * dereferences a pointer, meaning it gives you the value stored at that address. In a type, *T means "pointer to T".
package main

import "fmt"

func main() {
    age := 30

    var p *int = &age // p holds the address of age

    fmt.Println(age) // 30
    fmt.Println(p)   // 0xc000012345 (an address)
    fmt.Println(*p)  // 30 (the value at that address)

    *p = 31          // change the value through the pointer
    fmt.Println(age) // 31
}
Enter fullscreen mode Exit fullscreen mode

We changed age without ever assigning to age directly. The pointer p reached through the address and modified the original.

Pass by Value vs. Pass by Pointer

Go is always pass-by-value. When you call a function, every argument is copied. For a pointer, the thing being copied is the address, so the function still reaches the original data.

func incrementValue(n int) {
    n++ // modifies the local copy only
}

func incrementPointer(n *int) {
    *n++ // modifies the original
}

func main() {
    x := 10

    incrementValue(x)
    fmt.Println(x) // 10, unchanged

    incrementPointer(&x)
    fmt.Println(x) // 11, changed
}
Enter fullscreen mode Exit fullscreen mode

Pointers with Structs

Structs are where you will use pointers most often. Go gives you a convenient shortcut here: you can access fields through a pointer without writing (*p).Field. Go dereferences automatically.

type User struct {
    Name  string
    Email string
}

func updateEmail(u *User, email string) {
    u.Email = email // same as (*u).Email = email
}

func main() {
    user := User{Name: "Musa", Email: "old@example.com"}
    updateEmail(&user, "new@example.com")
    fmt.Println(user.Email) // new@example.com
}
Enter fullscreen mode Exit fullscreen mode

This is also why methods with pointer receivers are so common. If a method needs to modify the struct, or the struct is large and copying it would be wasteful, use func (u *User) ....

Pointers and Arrays

This is the part that surprises many developers. In Go, an array is a value type. Its length is part of its type ([3]int and [4]int are different types), and assigning or passing an array copies every element.

Example 1: Passing an Array by Value

func doubleByValue(arr [3]int) {
    for i := range arr {
        arr[i] *= 2
    }
    fmt.Println("inside function:", arr)
}

func main() {
    numbers := [3]int{1, 2, 3}

    doubleByValue(numbers)
    fmt.Println("after call:    ", numbers)
}
Enter fullscreen mode Exit fullscreen mode

Output:

inside function: [2 4 6]
after call:     [1 2 3]
Enter fullscreen mode Exit fullscreen mode

The function received a full copy of the array. It doubled its copy, and the original was never touched.

Example 2: Passing a Pointer to an Array

func doubleByPointer(arr *[3]int) {
    for i := range arr {
        arr[i] *= 2 // Go auto-dereferences: same as (*arr)[i]
    }
    fmt.Println("inside function:", *arr)
}

func main() {
    numbers := [3]int{1, 2, 3}

    doubleByPointer(&numbers)
    fmt.Println("after call:    ", numbers)
}
Enter fullscreen mode Exit fullscreen mode

Output:

inside function: [2 4 6]
after call:     [2 4 6]
Enter fullscreen mode Exit fullscreen mode

Now the function works on the original array. Two benefits follow: the changes are visible to the caller, and no copy is made. For a [1000000]int array, that is the difference between copying about 8 MB on every call and copying a single 8-byte address.

Go lets you index (arr[i]), use len(arr), and range over a pointer to an array directly, so the syntax stays clean.

Example 3: A Pointer to a Single Array Element

You can take the address of any element:

numbers := [3]int{10, 20, 30}

p := &numbers[1] // pointer to the second element
*p = 99

fmt.Println(numbers) // [10 99 30]
Enter fullscreen mode Exit fullscreen mode

Example 4: An Array of Pointers

This is a different thing from a pointer to an array. Here, each slot holds an address:

a, b, c := 1, 2, 3

ptrs := [3]*int{&a, &b, &c}

for _, p := range ptrs {
    *p *= 10
}

fmt.Println(a, b, c) // 10 20 30
Enter fullscreen mode Exit fullscreen mode

The array was copied if you pass it around, but every copy still points to the same a, b, and c. Keep the distinction clear:

Type Meaning
*[3]int One pointer to a whole array of three ints
[3]*int An array holding three pointers to ints

What About Slices?

In day-to-day Go, you will use slices far more than arrays. A slice is a small struct (a pointer to an underlying array, a length, and a capacity). When you pass a slice to a function, that small header is copied, but it still points to the same underlying array, so changes to elements are visible to the caller:

func doubleSlice(s []int) {
    for i := range s {
        s[i] *= 2
    }
}

func main() {
    nums := []int{1, 2, 3}
    doubleSlice(nums)
    fmt.Println(nums) // [2 4 6]
}
Enter fullscreen mode Exit fullscreen mode

One caveat: if the function calls append and the slice grows beyond its capacity, a new underlying array is allocated and the caller will not see the new length. Return the slice or pass *[]int in that case.

Nil Pointers and new

The zero value of a pointer is nil. Dereferencing a nil pointer causes a runtime panic, so check before you use one when it can legitimately be nil.

var p *int

if p != nil {
    fmt.Println(*p)
}
Enter fullscreen mode Exit fullscreen mode

The built-in new(T) allocates a zero-valued T and returns a pointer to it:

p := new(int)
*p = 42
fmt.Println(*p) // 42
Enter fullscreen mode Exit fullscreen mode

For structs, the more idiomatic form is &User{Name: "Musa"}.

You may wonder whether returning a pointer to a local variable is dangerous, as it would be in C. In Go it is perfectly safe. The compiler performs escape analysis and moves the variable to the heap if it outlives the function, and the garbage collector cleans it up later.

func newCounter() *int {
    count := 0
    return &count // safe in Go
}
Enter fullscreen mode Exit fullscreen mode

When Should You Use Pointers?

Use a pointer when:

  • The function or method must modify the original value.
  • The value is large (big structs or arrays) and copying would be expensive.
  • You need to represent "no value" with nil.
  • A type has some pointer-receiver methods, in which case keep all its methods consistent and use pointer receivers.

Prefer plain values when:

  • The data is small (ints, bools, small structs) and copying is cheap.
  • You want immutability: a copy guarantees the callee cannot change your data.
  • You want to reduce heap allocations and garbage collector work. Pointers are not automatically faster, and values often are.

Common Pitfalls

  1. Dereferencing nil. Always guard pointers that may be nil.
  2. Assuming arrays behave like slices. Passing an array copies it. Use a pointer or a slice if you need to modify the original.
  3. Unintended shared state. If two parts of your code hold the same pointer, a change in one place affects the other. Make ownership clear.
  4. Pointers to loop variables. Before Go 1.22, &v inside a for _, v := range loop pointed to a single shared variable. Since Go 1.22 each iteration gets its own variable, but if you support older versions, copy the value first.
  5. Overusing pointers for small types. Pointers to ints and strings usually add noise and allocations without any benefit.

Key Takeaways

  • &x gives the address of x, and *p gives the value at p.
  • Go is always pass-by-value. Passing a pointer copies the address, not the data.
  • Arrays are values: passing one copies it. Use *[N]T to modify the original and avoid the copy.
  • *[3]int (pointer to an array) and [3]*int (array of pointers) are different types.
  • Slices already carry a pointer to their underlying array, which is why they are the default choice.
  • Use pointers deliberately, for mutation, large data, or optional values, not by habit.

Conclusion

Pointers in Go are intentionally minimal: no arithmetic, automatic dereferencing for fields and indexing, and a garbage collector that keeps you safe. Master the difference between a value and its address, remember that arrays are copied, and you will have the foundation for writing efficient, predictable Go.

Happy coding!

Top comments (0)