Pointers Explained

Updated

September 13, 2026

Pointers Explained

A pointer is a value that holds the address of another value. The boring default is: take an address with &, follow it with *, check nil before you follow, and never do arithmetic. Go is not C.

Mental model

Piece Meaning
T a value of type T
*T a pointer to a T
&x the address of x (type *T if x is T)
*p the T that p points at
nil a pointer that points at nothing

Zero value of *T is nil. Following it panics. new(T) allocates a zero T and returns *T — same idea as p := new(int) versus var n int; p := &n, except new does not give you a named local.

There is no p + 1, no p[i] on a pointer to a single value, no free. The GC owns the bytes. Slices and maps are the tools for collections; pointer arithmetic is not.

Worked examples

Case 1: & and *

Save as table_ptr.go. p and &table are the same address. Writing through *p writes table.

// table_ptr.go
package main

import "fmt"

func main() {
    table := 12
    p := &table
    fmt.Println("table:", table)
    fmt.Println("same address:", p == &table)
    fmt.Println("*p:", *p)
    *p = 7
    fmt.Println("table after:", table)
}

Run:

go run table_ptr.go

Output:

table: 12
same address: true
*p: 12
table after: 7

We do not print p itself. The hex address changes every run and teaches nothing.

Case 2: nil is a value you must test

Save as label_table.go. A function that takes *int should decide what nil means before it stars the pointer.

// label_table.go
package main

import "fmt"

func label(p *int) string {
    if p == nil {
        return "no table"
    }
    return fmt.Sprintf("table %d", *p)
}

func main() {
    fmt.Println(label(nil))
    n := 4
    fmt.Println(label(&n))
}

Run:

go run label_table.go

Output:

no table
table 4

nil here is a legitimate “no table,” not a crash. Document that in the function name or a comment if the rest of the desk might guess wrong.

Case 3: new gives a zero and a pointer

Save as new_table.go. new(int) is a pointer to 0. Useful when you need a *T and do not already have a variable.

// new_table.go
package main

import "fmt"

func main() {
    p := new(int)
    fmt.Println("*p:", *p)
    *p = 12
    fmt.Println("*p:", *p)
}

Run:

go run new_table.go

Output:

*p: 0
*p: 12

For structs, composite literals are clearer: p := &Ticket{ID: 41} instead of p := new(Ticket); p.ID = 41. Keep new for simple zeros, or skip it.

Case 4: Pointer arithmetic does not exist

Save as arith.go. This is not a program you run successfully. It is the compiler telling you Go will not pretend a pointer is an index.

// arith.go
package main

func main() {
    table := 12
    p := &table
    p = p + 1
    _ = p
}

Run:

go run arith.go

Output:

# command-line-arguments
./arith.go:7:6: invalid operation: p + 1 (mismatched types *int and untyped int)

Walk a slice with range or an index. Walk a struct with field names. If you think you need to add to a pointer, you want a slice, an array, or a redesign.

Case 5: Struct pointers and automatic dereferencing

Save as struct_ptr.go. In C, you must switch between . and ->. In Go, t.Table is automatic syntactic sugar for (*t).Table.

// struct_ptr.go
package main

import "fmt"

type Ticket struct {
    ID    int
    Table int
}

func main() {
    t := &Ticket{ID: 101, Table: 4}
    // Automatic dereference: t.Table means (*t).Table
    t.Table = 7
    (*t).ID = 102
    fmt.Printf("ticket %d at table %d\n", t.ID, t.Table)
}

Run:

go run struct_ptr.go

Output:

ticket 102 at table 7

Both forms compile and mutate the same backing struct. The dot notation t.Table is idiomatic.

Case 6: Returning pointers to local variables is safe

In languages like C or C++, returning the address of a local stack variable produces a dangling pointer and undefined memory behavior. In Go, the compiler performs escape analysis: if the address of a local variable escapes the function scope, Go automatically allocates that variable on the heap.

Save as ticket_factory.go:

// ticket_factory.go
package main

import "fmt"

type Ticket struct {
    ID    int
    Table int
}

func newTicket(id, table int) *Ticket {
    t := Ticket{ID: id, Table: table}
    return &t // Escapes to heap: safe in Go!
}

func main() {
    t1 := newTicket(41, 12)
    t2 := newTicket(42, 14)
    fmt.Println("t1:", t1.ID, t1.Table)
    fmt.Println("t2:", t2.ID, t2.Table)
}

Run:

go run ticket_factory.go

Output:

t1: 41 12
t2: 42 14

You do not need to call malloc or worry about stack lifetimes. Go manages the allocation location for you.

The trap

A nil pointer prints as <nil> and then explodes when you follow it. The print is not a safety check.

Save as nil_deref.go:

// nil_deref.go
package main

import "fmt"

func main() {
    var p *int
    fmt.Println(p)
    fmt.Println(*p)
}

Run:

go run nil_deref.go

Output (the pc= hex and file path depend on your machine):

<nil>
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x...]

goroutine 1 [running]:
main.main()
    nil_deref.go:9
exit status 2

The fix is Case 2: test p == nil before *p. In methods, either refuse to run on a nil receiver or handle nil on purpose. Do not assume “I constructed this, so it cannot be nil” once the pointer has crossed a function boundary.

The boring rule

  • &x to share or mutate x. *p to read or write the value.
  • Use t.Field directly on struct pointers; do not write (*t).Field unless resolving ambiguity.
  • Returning &local from a function is idiomatic and completely safe. Go’s escape analysis moves it to the heap.
  • Check nil at the edge where a missing pointer is allowed. Panic is for bugs, not for empty tables.
  • Prefer &Struct{...} over new plus field assignments.
  • Never add, subtract, or index a pointer. Use a slice.
  • Do not print addresses in logs. Print the fields that matter to the desk.

Try this

  1. In table_ptr.go, add a function retarget(p *int, n int) that sets *p = n. Call it with &table and 3.
  2. In ticket_factory.go, run go build -gcflags="-m" ticket_factory.go to see the compiler’s escape analysis report moved to heap: t.
  3. In label_table.go, pass a pointer to 0. Confirm you print table 0, not no table. Nil and zero are different.
  4. Replace new(int) in new_table.go with a named var n int and p := &n. Same prints.
  5. Try p++ in arith.go. Same kind of error: pointers are not counters.