Pointers Explained
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.goOutput:
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.goOutput:
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.goOutput:
*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.goOutput:
# 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.goOutput:
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.goOutput:
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.goOutput (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
&xto share or mutatex.*pto read or write the value.- Use
t.Fielddirectly on struct pointers; do not write(*t).Fieldunless resolving ambiguity. - Returning
&localfrom a function is idiomatic and completely safe. Go’s escape analysis moves it to the heap. - Check
nilat the edge where a missing pointer is allowed. Panic is for bugs, not for empty tables. - Prefer
&Struct{...}overnewplus 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
- In
table_ptr.go, add a functionretarget(p *int, n int)that sets*p = n. Call it with&tableand 3. - In
ticket_factory.go, rungo build -gcflags="-m" ticket_factory.goto see the compiler’s escape analysis reportmoved to heap: t. - In
label_table.go, pass a pointer to0. Confirm you printtable 0, notno table. Nil and zero are different. - Replace
new(int)innew_table.gowith a namedvar n intandp := &n. Same prints. - Try
p++inarith.go. Same kind of error: pointers are not counters.