Loading
All articles
Programming 15 min read

Pointers in Go: A Visual, Hands-On Guide

Learn Go pointers visually: & and *, nil and the panic it can cause, changing values inside functions, new, pointers to structs, pointer receivers, why slices and maps behave differently, the for loop trap, a real benchmark, and exercises.

nbkhope
On this page

Every variable in your program lives somewhere in memory, and that place has an address. A pointer is a variable that stores one of those addresses. With it, two parts of your program can work on the same value instead of on separate copies.

Pointers have a scary reputation, mostly earned in languages like C. Go's are much tamer: there are only two operators to learn, there's no pointer arithmetic, and the garbage collector frees memory for you.

In this guide you'll learn those two operators, what nil means, how pointers let a function change your variables, and how they work with structs and methods. You'll also see why slices and maps act differently, and a benchmark that may change your mind about when pointers are faster. Exercises wrap it up.

All outputs below come from real runs with Go 1.22 on Windows. New to Go? Start with Your First Program in Go.

Your first pointer

1package main
2
3import "fmt"
4
5func main() {
6	var score int = 37
7	var pointer *int = &score
8
9	fmt.Println("score:", score)
10	fmt.Println("pointer:", pointer)
11	fmt.Println("*pointer:", *pointer)
12
13	*pointer = 62
14	fmt.Println("score is now:", score)
15}
1score: 37
2pointer: 0xc00000a0d8
3*pointer: 37
4score is now: 62

(The address will be different on your machine, and may change from run to run.)

Three new pieces of syntax appear here:

  • *int in a type means "a pointer to an int".
  • &score means "the address of score". & is the address-of operator.
  • *pointer in an expression means "the value at this address". Following a pointer like this is called dereferencing.
A pointer is a variable that holds an address. Two boxes in memory: pointer, of type *int, holds the address 0xc00000a0d8, and an arrow leads from it to score, of type int, which holds 37 and lives at the address 0xc00000a0d8. &score gives the address of score. *pointer follows the arrow, reading or writing the value at that address

The last assignment, *pointer = 62, never mentions score, yet score changed. There's only one integer here, and the pointer is a second way to reach it.

Most of the time you'd let Go infer the type and write pointer := &score, as the rest of this guide does.

A pointer versus a copy

Assigning one variable to another copies the value. Taking its address doesn't:

1package main
2
3import "fmt"
4
5func main() {
6	original := 10
7
8	copied := original
9	copied = 20
10	fmt.Println("copied:", copied, "original:", original)
11
12	pointer := &original
13	*pointer = 30
14	fmt.Println("*pointer:", *pointer, "original:", original)
15}
copied: 20 original: 10
*pointer: 30 original: 30

Changing copied left original alone, because they're two separate integers. Writing through pointer changed original, because the pointer leads to it.

nil: a pointer to nothing

A pointer you haven't set has the zero value nil, which means it points to nothing. Dereferencing nil crashes the program:

1package main
2
3import "fmt"
4
5func main() {
6	var pointer *int
7	fmt.Println(pointer)
8	fmt.Println(pointer == nil)
9	fmt.Println(*pointer)
10}
<nil>
true
panic: runtime error: invalid memory address or nil pointer dereference

This is the most common pointer bug in Go. It compiles fine and fails only when the program runs, so check for nil whenever a pointer might not be set.

That same nil is also useful. It can mean "no value", which is different from a zero:

1package main
2
3import "fmt"
4
5func describe(age *int) string {
6	if age == nil {
7		return "age not provided"
8	}
9	return fmt.Sprintf("age %d", *age)
10}
11
12func main() {
13	age := 0
14	fmt.Println(describe(&age))
15	fmt.Println(describe(nil))
16}
age 0
age not provided

A plain int couldn't tell "the age is 0" apart from "nobody told us the age". A *int can.

Letting a function change your variable

Go always passes arguments by value: the function receives a copy. So how does a function change a variable that belongs to its caller? You give it the variable's address:

1package main
2
3import "fmt"
4
5func doubleCopy(number int) {
6	number *= 2
7}
8
9func doubleInPlace(number *int) {
10	*number *= 2
11}
12
13func main() {
14	value := 21
15
16	doubleCopy(value)
17	fmt.Println("after doubleCopy:", value)
18
19	doubleInPlace(&value)
20	fmt.Println("after doubleInPlace:", value)
21}
after doubleCopy: 21
after doubleInPlace: 42
Go always passes a copy, and what gets copied is what matters. On the left, doubleCopy(value) copies the number: main's value holds 21, the function's number starts as a copy of 21 and becomes 42, and that copy disappears when the function returns, so value is still 21. On the right, doubleInPlace(&value) copies the address: the function's number holds &value and leads to main's value, which goes from 21 to 42

doubleInPlace also receives a copy, but it's a copy of the address. That address still leads to the caller's variable, so *number *= 2 changes the original.

A classic example is swapping two variables:

1package main
2
3import "fmt"
4
5func swap(first, second *int) {
6	*first, *second = *second, *first
7}
8
9func main() {
10	left, right := 1, 2
11	swap(&left, &right)
12	fmt.Println(left, right)
13}
2 1

Creating pointers: new, and returning local variables

The built-in new function creates a zero-valued variable and gives you a pointer to it:

1package main
2
3import "fmt"
4
5func main() {
6	counter := new(int)
7	fmt.Println(*counter)
8
9	*counter = 5
10	*counter++
11	fmt.Println(*counter)
12}
0
6

You won't see new very often, because & usually reads better. In C, returning the address of a local variable is a serious bug. In Go, it's completely safe:

1package main
2
3import "fmt"
4
5func newCounter() *int {
6	count := 100
7	return &count
8}
9
10func main() {
11	first := newCounter()
12	second := newCounter()
13	*first += 1
14	fmt.Println(*first, *second)
15}
101 100

The compiler notices that count outlives the function, so it allocates count on the heap, the long-lived area of memory that the garbage collector manages. This decision is called escape analysis, and you can watch it happen:

go build -gcflags=-m .
./main.go:6:2: moved to heap: count

Each call creates its own count, which is why first and second end up with different values.

Pointers to structs

Structs are where you'll use pointers most. Go lets you skip the * when you reach a field through a pointer:

1package main
2
3import "fmt"
4
5type Player struct {
6	Name  string
7	Level int
8}
9
10func levelUp(player *Player) {
11	player.Level++
12}
13
14func main() {
15	hero := Player{Name: "Ana", Level: 1}
16	levelUp(&hero)
17	fmt.Println(hero)
18
19	pointer := &hero
20	fmt.Println(pointer.Name, (*pointer).Name)
21	fmt.Println(pointer)
22	fmt.Printf("%+v\n", *pointer)
23}
1{Ana 2}
2Ana Ana
3&{Ana 2}
4{Name:Ana Level:2}

pointer.Name is shorthand for (*pointer).Name, and the short form is what everyone writes. Printing a pointer to a struct shows &{…} instead of a raw address.

Pointer receivers on methods

The same value-or-pointer choice applies to methods. A receiver is the variable before the method's name, and it's copied just like an argument:

1package main
2
3import "fmt"
4
5type Counter struct {
6	value int
7}
8
9func (counter Counter) IncrementCopy() {
10	counter.value++
11}
12
13func (counter *Counter) Increment() {
14	counter.value++
15}
16
17func main() {
18	counter := Counter{}
19
20	counter.IncrementCopy()
21	fmt.Println("after IncrementCopy:", counter.value)
22
23	counter.Increment()
24	fmt.Println("after Increment:", counter.value)
25}
after IncrementCopy: 0
after Increment: 1

IncrementCopy changed a copy that was thrown away. Increment has a pointer receiver, so it changed the real counter. Notice that the call is just counter.Increment(): Go takes the address for you.

A good rule: if a method needs to change its receiver, use a pointer receiver. If any method on a type uses one, use it on all of that type's methods for consistency.

Slices and maps: already sharing

Slices and maps look like they break the rules. Here, functions change them with no pointer in sight, until append comes along:

1package main
2
3import "fmt"
4
5func changeFirst(numbers []int) {
6	numbers[0] = 99
7}
8
9func appendOne(numbers []int) {
10	numbers = append(numbers, 4)
11}
12
13func appendOneThroughPointer(numbers *[]int) {
14	*numbers = append(*numbers, 4)
15}
16
17func addEntry(ages map[string]int) {
18	ages["Ana"] = 30
19}
20
21func main() {
22	numbers := []int{1, 2, 3}
23
24	changeFirst(numbers)
25	fmt.Println(numbers)
26
27	appendOne(numbers)
28	fmt.Println(numbers)
29
30	appendOneThroughPointer(&numbers)
31	fmt.Println(numbers)
32
33	ages := map[string]int{}
34	addEntry(ages)
35	fmt.Println(ages)
36}
1[99 2 3]
2[99 2 3]
3[99 2 3 4]
4map[Ana:30]

Arguments are still copied, but a slice is a small value that contains a pointer to an underlying array, plus a length and a capacity. The copy points to the same array, so changeFirst changes elements the caller can see. appendOne, though, only updated the length in its own copy, so the caller's slice stayed at three elements.

In practice you rarely need a *[]int. The usual Go style is to return the new slice, which is why you write numbers = append(numbers, 4). A map also holds a pointer internally, so you don't need a pointer to a map to add entries.

Two loop details

Changing elements in a range loop. The range variable is a copy of the element, so changing it does nothing to the slice. Take a pointer to the element instead:

1package main
2
3import "fmt"
4
5type Player struct {
6	Name  string
7	Level int
8}
9
10func main() {
11	players := []Player{{"Ana", 1}, {"Bruno", 1}}
12
13	for _, player := range players {
14		player.Level = 10
15	}
16	fmt.Println(players)
17
18	for index := range players {
19		player := &players[index]
20		player.Level = 10
21	}
22	fmt.Println(players)
23}
[{Ana 1} {Bruno 1}]
[{Ana 10} {Bruno 10}]

Taking the address of a loop variable. What does this print?

1package main
2
3import "fmt"
4
5func main() {
6	var pointers []*int
7	for number := 1; number <= 3; number++ {
8		pointers = append(pointers, &number)
9	}
10	for _, pointer := range pointers {
11		fmt.Print(*pointer, " ")
12	}
13	fmt.Println()
14}
1 2 3 

That's the answer since Go 1.22, where each pass through the loop gets its own number. The version comes from the go line in your go.mod, and with go 1.21 the same program printed 4 4 4, because all three pointers shared one variable. You may still run into that bug in older code.

What Go doesn't let you do

No pointer arithmetic. In C you can add to a pointer to walk through memory. Go refuses:

1package main
2
3import "fmt"
4
5func main() {
6	numbers := [3]int{10, 20, 30}
7	pointer := &numbers[0]
8	pointer++
9	fmt.Println(*pointer)
10}
./main.go:8:2: invalid operation: pointer++ (non-numeric type *int)

This rules out a whole family of memory bugs. (The unsafe package can bypass this, but as the name warns, ordinary code stays away from it.)

No address of a constant. A pointer has to lead to a variable:

1package main
2
3import "fmt"
4
5func main() {
6	pointer := &42
7	fmt.Println(*pointer)
8}
./main.go:6:14: invalid operation: cannot take address of 42 (untyped int constant)

Store the number in a variable first. The exception is a composite literal such as &Player{Name: "Ana"}, which is allowed and very common.

Are pointers faster?

A common belief is that passing a pointer is always faster because it avoids a copy. We measured it with testing.Benchmark on Go 1.22, comparing a 16-byte struct and a 1 KiB struct:

A pointer isn't automatically faster. Nanoseconds per call on Go 1.22.5 on Windows, median of 15 rounds, lower is better. Passing 16 bytes: 1.84 by value, 1.88 by pointer. Passing 1 KiB: 13.58 by value, 1.94 by pointer. Returning 16 bytes: 1.85 by value, 16.55 by pointer, which needs one heap allocation
WhatBy valueBy pointer
Passing a 16-byte struct1.84 ns1.88 ns
Passing a 1 KiB struct13.58 ns1.94 ns
Returning a 16-byte struct1.85 ns16.55 ns

The results show three different stories:

  • Small values: no measurable difference. Copying 16 bytes is nearly free.
  • Large values: the pointer wins by about 7 times, because the copy has real work to do.
  • Returning a new value: the pointer lost by about 9 times. In this test the value escaped to the heap, which cost one allocation per call, plus work for the garbage collector later.

So choose based on what your code means. Use a pointer when a function needs to change the value, when "no value" must be possible, or when the struct is large. Otherwise, a plain value is simpler, and often just as fast.

These are tiny, artificial functions, so treat the numbers as a rough guide and measure your own code before optimizing it.

Your turn: practice

Try these before peeking at the answers. Run each with go run ..

Exercise 1: Predict the output (warm-up). What does this print?

1package main
2
3import "fmt"
4
5func main() {
6	number := 5
7	pointer := &number
8	other := pointer
9
10	*other = 8
11	number++
12
13	fmt.Println(number, *pointer, *other)
14}

Exercise 2: Reset. Write a reset(number *int) function that sets the caller's variable to 0, and try it from main.

Exercise 3: Predict the arguments. What does this print, and why?

1package main
2
3import "fmt"
4
5func increaseAll(numbers []int, amount *int) {
6	for index := range numbers {
7		numbers[index] += *amount
8	}
9	*amount = 0
10}
11
12func main() {
13	numbers := []int{1, 2, 3}
14	amount := 10
15	increaseAll(numbers, &amount)
16	fmt.Println(numbers, amount)
17}

Exercise 4: A safe default. Write valueOr(pointer *int, fallback int) int, which returns the value the pointer leads to, or fallback if the pointer is nil.

Exercise 5: A linked list (challenge). Define a Node struct with a Value int and a Next *Node. Write push(head *Node, value int) *Node, which returns a new first node. Push 1, 2 and 3, then walk the list and print each value.

Quick quiz

  1. What's the difference between * in *int and * in *pointer?
  2. What's the zero value of a pointer, and what happens when you dereference it?
  3. Does Go pass arguments by value or by reference?
  4. Is it safe to return the address of a local variable in Go?
  5. Why can a function change a slice's elements without a pointer, but not its length?

Answers

Exercise 1: pointer and other both lead to number, so there's a single integer. It becomes 8, then 9, and all three expressions read it:

9 9 9

Exercise 2: Write through the pointer, and pass the address at the call:

1package main
2
3import "fmt"
4
5func reset(number *int) {
6	*number = 0
7}
8
9func main() {
10	points := 75
11	reset(&points)
12	fmt.Println(points)
13}
0

Exercise 3: The slice's copy shares its underlying array, so the elements change. The pointer leads to amount, so the function resets it to 0 after using it:

[11 12 13] 0

Exercise 4: Check for nil before dereferencing:

1package main
2
3import "fmt"
4
5func valueOr(pointer *int, fallback int) int {
6	if pointer == nil {
7		return fallback
8	}
9	return *pointer
10}
11
12func main() {
13	number := 7
14	fmt.Println(valueOr(&number, 100))
15	fmt.Println(valueOr(nil, 100))
16}
7
100

Exercise 5: Each new node points to the old first node, and nil marks the end of the list. Pushing to the front reverses the order:

1package main
2
3import "fmt"
4
5type Node struct {
6	Value int
7	Next  *Node
8}
9
10func push(head *Node, value int) *Node {
11	return &Node{Value: value, Next: head}
12}
13
14func main() {
15	var head *Node
16	for number := 1; number <= 3; number++ {
17		head = push(head, number)
18	}
19	for node := head; node != nil; node = node.Next {
20		fmt.Print(node.Value, " ")
21	}
22	fmt.Println()
23}
3 2 1 

Quiz: (1) In *int, it's part of a type: "pointer to an int". In *pointer, it's an operator that follows the pointer to its value. (2) nil; dereferencing it causes a panic. (3) By value, always. Passing a pointer copies the address. (4) Yes. Escape analysis moves the variable to the heap, so it stays valid. (5) The copied slice shares the caller's underlying array, but its length is stored in the copy itself.

Cheat sheet

I want to…Write
Declare a pointer to an intvar pointer *int
Get a variable's addresspointer := &score
Read the value*pointer
Change the value*pointer = 62
Check for no valueif pointer == nil { … }
Let a function change a variablefunc double(number *int), called with double(&value)
Create a zero-valued variablecounter := new(int)
Create a struct and get a pointerhero := &Player{Name: "Ana"}
Reach a field through a pointerhero.Name
Let a method change its receiverfunc (counter *Counter) Increment()
Change slice elements in a loopplayer := &players[index]
See what moves to the heapgo build -gcflags=-m .

Wrapping up

A pointer is an address, and a small set of rules covers how it behaves:

  • & gets an address, and * follows it. In a type, *int means "pointer to an int".
  • The zero value is nil. Dereferencing it panics, but it's also a handy way to say "no value".
  • Go always passes copies. Passing a pointer copies the address, which is how a function changes your variable.
  • Returning the address of a local variable is safe, because escape analysis moves it to the heap.
  • Fields and methods are convenient: pointer.Name and counter.Increment() handle the * and & for you.
  • Slices and maps already share their data, though a slice's length belongs to each copy.
  • Pointers aren't automatically faster: 1.94 ns vs. 13.58 ns for a 1 KiB struct, but 16.55 ns vs. 1.85 ns when returning a small one that escapes.

Back through the series: How to Use defer in Go, Two-Dimensional Slices in Go and Your First Program in Go. Next up: Structs in Go.

Thanks for reading!

Know someone who would enjoy this? Send it their way.

More articles

Read next

Programming 4 min read

Ruby Blocks and Iterators: Write Your Own

Learn how Ruby methods receive blocks, pass values with yield, handle a missing block, and return an Enumerator. Build and run your own iterator.

nbkhope
Programming 6 min read

C++ std::vector: Size, Capacity, and Safe Growth

Learn how std::vector grows in C++: the difference between size and capacity, when reserve helps, why resize is different, and how growth can invalidate references.

nbkhope
Programming 5 min read

How to Handle Errors in Go: A Hands-On Guide

Learn Go's everyday error pattern with runnable examples: return an error, check it, add context with %w, and recognize a missing file with errors.Is.

nbkhope

Comments

Be kind and specific. Comments with links from new accounts appear after a moderator reviews them.