Course outline
Course / The terminal and the language

Pointers, the stack, and the heap

Reading

The idea

Every variable you have written lives somewhere in your computer's memory, at a numeric address. Most of the time you never think about that address: you use the variable's name and Go handles the rest. A pointer is a value that holds an address instead of ordinary data like a number or a string. Think of it as a slip of paper with an address written on it. The slip is not the thing itself, but it tells you exactly where to go, and handing someone the slip lets them find and change the very same thing you have, instead of a copy of it.

Nothing new to install for this lesson, everything runs with the go tool you already have. Snippets that start with package main are full programs you can paste into main.go. The others go inside func main(), and any function declaration they use goes outside it, at the top level of the file.

address-of & and dereference *

Every variable is a labeled box holding a value. The & operator, read "address-of", gives you a slip of paper with that box's address on it: a pointer. The * operator, read "dereference", does the opposite: given a slip of paper, it walks to that address and lets you read or write what is inside the box.

lives := 3
p := &lives
fmt.Println(*p)
fmt.Printf("%T\n", p)
3
*int

p is a pointer to the box lives, so *p reads the 3 inside it. Its type is *int, "pointer to int", which you read right to left: a pointer, to an int. There is a pointer type for every type: *string, *bool, *float64 and so on. If you print p itself you see an address such as 0xc000012345, a different number on every run, so never write code that depends on what a specific address looks like.

* also works on the left side of =, to write through the pointer instead of just reading:

*p = 2
fmt.Println(lives)
*p++
fmt.Println(lives)
2
3

*p = 2 does not change what p points to. It changes the value sitting at that address, and since the address is lives's box, lives itself is now 2. The other operators work through a pointer too: *p++ adds one to the box.

Two pointers are equal only when they hold the same address, not when the values inside happen to match:

a := 4
b := 4
pa := &a
pb := &b
fmt.Println(pa == pb, *pa == *pb, pa == &a)
false true true

a and b are different boxes that both hold 4, so the pointers differ but the values are equal. You can only take the address of a variable, not of a bare value, and Go has no pointer arithmetic, so you cannot add one to an address to walk to the next box:

package main

import "fmt"

func main() {
	p := &5
	fmt.Println(p)
}
./main.go:6:8: invalid operation: cannot take address of 5 (untyped int constant)

5 is a value with no box, so there is nothing to point at. Put it in a variable first and take the address of that. Likewise p++ on a pointer is rejected with non-numeric type *int: use *p++ to change the number it points to.

pointer parameters

You have already seen that Go passes arguments by value: a function gets its own copy of whatever you pass, so changes inside it do not touch the caller's variable. A pointer parameter is how you opt out. Instead of handing the function a copy of your box, you hand it a slip of paper pointing at your box, so it can reach in and change the original. Reading through the pointer and writing the new value back is the whole trick:

package main

import (
	"fmt"
	"strings"
)

func shout(s *string) {
	*s = strings.ToUpper(*s) + "!"
}

func toggle(on *bool) {
	*on = !*on
}

func main() {
	msg := "hello"
	shout(&msg)
	fmt.Println(msg)

	lamp := false
	toggle(&lamp)
	fmt.Println(lamp)
	toggle(&lamp)
	fmt.Println(lamp)
}
HELLO!
true
false

shout still only ever receives a copy, but this time the copy is of the address, not of the text. Both the copy (s) and the original (&msg) point at the same box, so *s = ... changes the box that msg is the name for. On the right side of that line, *s reads the current text, strings.ToUpper and + "!" build the new text, and the assignment writes it back. toggle does the same with a bool, which is why calling it twice puts the lamp back where it started.

Note the & at the call: you pass &msg, the address, because the parameter's type is *string and not string. Passing msg itself is a type error.

nil pointers

A plain int variable's zero value is 0. A pointer's zero value is nil, a slip of paper that points nowhere. Declaring a pointer without pointing it at anything gives you nil, and dereferencing a nil pointer is one of the few ways to crash a Go program outright:

package main

import "fmt"

func main() {
	var p *int
	fmt.Println(p)
	fmt.Println(*p)
}
<nil>
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x491b2f]

goroutine 1 [running]:
main.main()

Read it piece by piece. panic: means the program stopped itself rather than continue with something it cannot trust. invalid memory address or nil pointer dereference is Go telling you plainly what happened: you tried to follow a slip of paper that had no address on it. SIGSEGV is the operating system's name for "you touched memory you do not own", and addr=0x0 is the address it tried to reach: 0x0, zero, which is exactly what nil means. Below that, Go prints a trace of where it happened, and the main.go line number in it is the line to fix.

Pay attention to one distinction that trips up beginners: a pointer to a zero value is not a nil pointer. The first has a box that happens to hold 0, the second has no box at all.

retries := 0
q := &retries
fmt.Println(q == nil, *q)
var none *int
fmt.Println(none == nil, none)
false 0
true <nil>

q points at a real box holding 0, so it is safe to dereference. none points at nothing. Guard against nil before dereferencing, the same way you would guard any risky input:

package main

import "fmt"

func main() {
	var nickname *string
	if nickname != nil {
		fmt.Println("hi,", *nickname)
	} else {
		fmt.Println("no nickname set")
	}

	name := "Ada"
	nickname = &name
	if nickname != nil {
		fmt.Println("hi,", *nickname)
	}
}
no nickname set
hi, Ada

This is why pointers are sometimes used on purpose for "maybe there is a value": nil can mean "not provided", which a plain string or int cannot say, because an empty string or 0 is a real answer.

the stack

Picture a pile of dinner plates: you can only ever add a plate to the top, or take the top plate away, never pull one from the middle. Every function call in your program adds a plate like that, holding its parameters and local variables. The moment the function returns, Go removes its plate and everything on it disappears. This pile is called the stack, and each individual plate is a stack frame.

package main

import "fmt"

func markPaid(paid *bool) {
	*paid = true
}

func main() {
	invoice := false
	markPaid(&invoice)
	fmt.Println(invoice)
}
true

When main starts, it gets a plate holding invoice. Calling markPaid adds a new plate on top, holding paid, which is just the address of invoice down on main's plate underneath. *paid = true reaches down through that address and changes invoice directly. When markPaid returns, its plate is removed. main's plate, and invoice on it, are untouched and already carry the new value. This is exactly why pointer parameters work: the pointer only has to reach a plate that is still there.

the heap

Sometimes a value has to outlive the plate it was created on, most often when a function hands back the address of one of its own local variables. Go cannot leave that value on the function's stack frame, because that plate is removed the instant the function returns and whatever was on it would be gone. So instead, Go stores the value in the heap: a big shared storage area that is not tied to any one function call. Think of it like a warehouse shared by the whole program, with a manager, Go's garbage collector, who keeps track of what is still being used and clears out anything nobody points to any more.

package main

import (
	"fmt"
	"strconv"
)

func labelFor(id int) *string {
	label := "item-" + strconv.Itoa(id)
	return &label
}

func main() {
	a := labelFor(1)
	b := labelFor(2)
	fmt.Println(*a, *b, a == b)
}
item-1 item-2 false

label looks like an ordinary local variable, the kind that should vanish when labelFor returns. But its address is returned, so something outside the function can still reach it afterward. Go notices this and stores label in the heap instead of on labelFor's stack frame, so it survives the return. Each call gets its own fresh box, which is why the two pointers are different (a == b is false) and each still holds its own text. In a language like C this exact pattern is a bug, because the stack space gets reused and the pointer goes stale. Go makes it safe automatically.

There is also a built-in way to ask for a fresh box directly. new(int) creates an int set to its zero value and hands you a pointer to it:

n := new(int)
fmt.Println(*n)
*n = 7
fmt.Println(*n)
0
7

new(T) works for any type: new(string) gives a pointer to an empty string, and new(bool) a pointer to false. You will meet it in older Go code. Most Go programmers prefer to declare a variable and take its address.

escape analysis basics

Escape analysis is the step where the compiler decides, for each variable, whether it can stay on the stack or has to go to the heap instead, whether it "escapes" the function that created it. A variable escapes when something outside the function might still reach it afterward: its address gets returned, stored somewhere longer-lived, or captured by a closure that outlives the call. You never write code to request this. The compiler works it out on its own, and it only ever affects performance, never correctness: a value on the heap behaves exactly the same as one on the stack, just with a little more bookkeeping for the garbage collector.

You can watch the compiler make these decisions. Put this in main.go:

package main

import "strconv"

func labelFor(id int) *string {
	label := "item-" + strconv.Itoa(id)
	return &label
}

func markPaid(paid *bool) {
	*paid = true
}

func main() {
	l := labelFor(7)
	_ = l
	var done bool
	markPaid(&done)
}
go build -gcflags="-m" . 2>&1 | grep -E "escape|moved to heap"

-gcflags passes a flag straight to the compiler instead of to go build itself, and -m asks it to print its escape-analysis decisions. It prints plenty of other lines too, so 2>&1 joins the compiler's messages to the normal output and grep keeps only the interesting ones:

./main.go:6:2: moved to heap: label
./main.go:6:19: "item-" + ~r0 escapes to heap
./main.go:10:15: paid does not escape
./main.go:15:15: "item-" + ~r0 does not escape

./main.go:6:2 is the file, line and column of the variable in question, and moved to heap: label is the compiler's verdict: label could not stay on the stack, because its address was returned. paid does not escape is the opposite verdict, for the pointer parameter of markPaid, which is only ever read and written and never stored or returned, so it stays on the stack exactly where it was passed in. The other lines are about temporary text values and vary between Go versions, so focus on the lines that name your own variables.

Common mistakes

  • Dereferencing a pointer without checking it against nil first, instead of guarding the way you would any other risky input.
  • Mixing up a nil pointer with a pointer to a zero value. q == nil asks whether there is a box at all, *q == 0 asks what is in it.
  • Assuming a returned &localVariable is a bug, out of C habit. In Go, escape analysis moves it to the heap automatically and it is safe to use.
  • Mixing up the pointer and the value it points to: printing p (an address) when you meant *p (the thing at that address), or the reverse.
  • Passing msg where the function wants *string. You must pass &msg.
  • Trying to take the address of a bare value like &5, or to add to a pointer like p++. Use a variable, and use *p++ to change the number it points to.
  • Reaching for a pointer parameter just to "modify" a value, when returning a new value instead would be simpler and easier to follow.
  • Assuming every pointer forces a heap allocation. Most pointer parameters only ever point back down to a plate that is still on the stack and never escape.

Try it

  1. Declare lives := 3 and p := &lives. Print p, then *p, then %T of p. Then run *p = 2 and *p++ and print lives after each.
  2. Write shout(s *string) and toggle(on *bool) from the lesson, call them with & on your own variables, and print the originals afterward. Then call shout(msg) without the & and read the type error.
  3. Declare var p *int, print it (you should see <nil>), then dereference it on purpose and read the panic message line by line, including the line number from the trace.
  4. Declare zero := 0 and q := &zero, and print q == nil and *q. Then print none == nil for a var none *int and compare the two results.
  5. Write the nickname guard from the lesson and run it with the pointer unset and then set.
  6. Write labelFor and call it twice. Print both results and a == b to confirm they are independent boxes. Then write n := new(int), set it to 7 and print it.
  7. Save the escape-analysis program from the lesson into its own folder, run the go build -gcflags="-m" command with the grep, and match each verdict line to your source by file, line and column.
  8. Try p := &5 and read the error, then try p++ on a *int and read that error too.

Something wrong with this lesson? Report a problem