38. Pointers: `&` and `*`

📖 Reading · 11 min
💡 Every code box below is live — edit it and hit Run.

A pointer holds the address of a value instead of the value itself. You've already used them — &Server{...}, pointer receivers, errors.New returning a pointer. This lesson makes the mechanics explicit.

Go's pointers are deliberately tame: no pointer arithmetic, no dangling pointers, no manual free. What's left is exactly two operations.

& takes an address, * follows one

package main

import "fmt"

func main() {
    x := 42
    p := &x

    fmt.Println("value of x:", x)
    fmt.Println("value of p:", p != nil)
    fmt.Println("value at p:", *p)

    *p = 100
    fmt.Println("x is now:", x)

    fmt.Printf("type of p: %T\n", p)
}
  • &x — "the address of x". The result has type *int.
  • *p — "the value at p". This is called dereferencing.

Assigning through *p changes x, because p points at x. There is one 42 in memory and two ways to reach it.

(We print p != nil rather than p itself because an address is a different number every run — and there's nothing useful in it.)

Declaring a pointer

package main

import "fmt"

func main() {
    var p *int
    fmt.Println("zero value:", p == nil)

    x := 7
    p = &x
    fmt.Println(*p)

    y := 9
    p = &y
    fmt.Println(*p, "and x is still", x)
}

*int is the type "pointer to int". Its zero value is nil — pointing at nothing.

Dereferencing a nil pointer panics, so code that might see one checks first: if p != nil { ... }. This is the same "check before you use it" discipline as if err != nil.

Why pointers exist: modifying the caller's value

Remember from module 3 that arguments are copies. A pointer is how a function reaches the original:

package main

import "fmt"

func bumpCopy(n int) {
    n += 100
}

func bumpReal(n *int) {
    *n += 100
}

func main() {
    x := 1

    bumpCopy(x)
    fmt.Println("after bumpCopy:", x)

    bumpReal(&x)
    fmt.Println("after bumpReal:", x)
}

Three things had to line up: the parameter type is *int, the call site passes &x, and the body writes through *n. Miss any one and it doesn't compile — the mutation is visible at the call site, which is the point.

Whenever you see & at a call site in Go code, read it as "this function can change my variable."

Structs and the automatic dereference

With structs, Go quietly does the dereferencing for you:

package main

import "fmt"

type User struct {
    Name string
    Age  int
}

func birthday(u *User) {
    u.Age++
}

func rename(u *User, name string) {
    (*u).Name = name
}

func main() {
    u := User{Name: "Ada", Age: 36}

    birthday(&u)
    rename(&u, "Ada L.")

    fmt.Printf("%+v\n", u)
}

u.Age++ on a *User is shorthand for (*u).Age++ — both forms are in that snippet and they do the same thing. Nobody writes the second. This convenience is why pointer receivers feel so natural in method bodies.

Returning a pointer is safe

In C, returning the address of a local variable is a bug. In Go it's routine:

package main

import "fmt"

type Config struct {
    Host string
    Port int
}

func newConfig(host string) *Config {
    c := Config{Host: host, Port: 8080}
    return &c
}

func main() {
    a := newConfig("alpha")
    b := newConfig("beta")

    b.Port = 9090

    fmt.Printf("%+v\n", *a)
    fmt.Printf("%+v\n", *b)
}

c is a local variable, and we return its address anyway. Go's compiler notices the address escapes the function and allocates c on the heap instead of the stack, where it stays alive as long as anything points at it. The garbage collector frees it when nothing does.

You never think about this. It's the single biggest quality-of-life difference between Go pointers and C pointers.

Pointers to pointers, and why you won't need them

package main

import "fmt"

func main() {
    x := 1
    p := &x
    pp := &p

    fmt.Println(**pp)

    **pp = 5
    fmt.Println(x)
}

Legal, occasionally necessary (a function that must replace your pointer), and rare enough that seeing ** in real Go code is a prompt to ask whether there's a simpler design.

What is not a pointer but behaves like one

This trips people up, so let's be precise. Slices, maps and channels are not pointers — but they contain one:

package main

import "fmt"

func modifySlice(s []int) {
    s[0] = 999
}

func appendSlice(s []int) {
    s = append(s, 4)
}

func modifyMap(m map[string]int) {
    m["new"] = 1
}

func modifyStruct(u User) {
    u.Name = "changed"
}

type User struct{ Name string }

func main() {
    s := []int{1, 2, 3}
    modifySlice(s)
    appendSlice(s)
    fmt.Println("slice:", s)

    m := map[string]int{}
    modifyMap(m)
    fmt.Println("map:", m)

    u := User{Name: "Ada"}
    modifyStruct(u)
    fmt.Println("struct:", u.Name)
}

Read the results carefully:

  • modifySlice worked — the slice header was copied, but it points at the same backing array.
  • appendSlice didn'tappend reassigned the local copy of the header. To change the caller's slice you need *[]int, or (much better) return the new slice.
  • modifyMap worked — maps are reference types all the way.
  • modifyStruct didn't — structs copy, which is why you saw *User in the last two modules.

The rule underneath is consistent: everything in Go is passed by value. Some of those values happen to contain pointers.

Your turn

Write swap(a, b *int) that exchanges the two values it's pointed at:

before: 1 2
after: 2 1
package main

import "fmt"

// write swap here

func main() {
    x, y := 1, 2
    fmt.Println("before:", x, y)
    swap(&x, &y)
    fmt.Println("after:", x, y)
}
package main

import "fmt"

func swap(a, b *int) {
    *a, *b = *b, *a
}

func main() {
    x, y := 1, 2
    fmt.Println("before:", x, y)
    swap(&x, &y)
    fmt.Println("after:", x, y)
}

Next: when to actually reach for one.