Pointers and references
A pointer in Go is a value referring to a memory location holding a value of some type. The principal operators are & (address-of) and * (dereference). Go pointers admit none of C’s distinctive pitfalls: there is no pointer arithmetic (no p++ or p + 1), pointers cannot be cast between unrelated types in safe Go, dangling pointers cannot exist (the garbage collector keeps any pointed-to memory alive), and nil dereference is a recoverable panic, not undefined behaviour. The conventional uses are: passing large structures by reference (avoiding a copy), modifying a value from a function (since Go is pass-by-value), expressing optional fields, and the value-vs-pointer-receiver choice for methods.
The principal operators
x := 10
p := &x // p is *int — a pointer to an int
fmt.Println(*p) // 10 (dereference)
*p = 20 // modify x through the pointer
fmt.Println(x) // 20
The form &x produces a pointer to x; the form *p is the value referred to by p. The *int syntax is the type of a pointer to int.
The nil pointer
A pointer’s zero value is nil:
var p *int // p == nil
fmt.Println(p == nil) // true
// Dereferencing nil panics:
// fmt.Println(*p) // PANIC: runtime error: invalid memory address
The conventional defences:
- Check for
nilbefore dereferencing. - Use the language’s
nilchecks (if p != nil). - Trust the garbage collector: a non-nil pointer always refers to live memory.
Pointers to struct fields
Pointers admit modifying struct fields through references:
type Point struct { X, Y float64 }
p := Point{X: 1, Y: 2}
ptr := &p
ptr.X = 10 // shorthand for (*ptr).X
fmt.Println(p.X) // 10
Go admits automatic dereference on field access — ptr.X is shorthand for (*ptr).X. The mechanism produces clean syntax for pointer-based struct manipulation.
Passing pointers to functions
Go is pass-by-value: a function receives a copy of its arguments. To modify the caller’s value, pass a pointer:
func increment(x int) {
x++ // modifies the local copy
}
func incrementPtr(p *int) {
*p++ // modifies the caller's value
}
x := 10
increment(x)
fmt.Println(x) // 10 (unchanged)
incrementPtr(&x)
fmt.Println(x) // 11 (modified)
The pattern is the conventional Go form for “in/out” parameters and “out” parameters.
Pointers to large structs
Even without modification, pointers admit avoiding a copy of large structs:
type Config struct {
/* 30 fields */
}
// Pass by value — copies all 30 fields:
func processValue(c Config) { /* ... */ }
// Pass by pointer — copies one pointer:
func processPointer(c *Config) { /* ... */ }
The conventional discipline:
- For small structs (a few fields), value semantics are conventionally fine.
- For large structs or when modification is intended, pointers are conventional.
- For “method receivers” — see “Value vs pointer receivers” below.
Returning pointers
Functions may return pointers to local variables; the garbage collector ensures the memory remains live:
func newPerson(name string, age int) *Person {
return &Person{Name: name, Age: age} // returning a pointer to a local
} // p is allocated on the heap (escape analysis)
p := newPerson("Alice", 30)
fmt.Println(p.Name) // "Alice"
The mechanism is a substantial improvement over C, where returning a pointer to a stack variable produces undefined behaviour. The Go compiler’s escape analysis determines when a value must be heap-allocated to keep the pointer valid.
The new and &T{} forms
Two ways to allocate a value and obtain a pointer:
p := new(int) // allocates a new int (zero value); returns *int
*p = 5
p := new(Point) // allocates a new Point (zero); returns *Point
p.X = 1.0
// More commonly:
p := &Point{X: 1.0, Y: 2.0} // allocate and initialise; returns *Point
p := &Point{} // allocate, all-zero; returns *Point
The &T{...} form is conventional; new(T) is rarely used in routine code.
Value vs pointer receivers
Methods may be declared with either a value or pointer receiver:
type Counter struct { n int }
// Value receiver:
func (c Counter) Get() int {
return c.n
}
// Pointer receiver:
func (c *Counter) Increment() {
c.n++ // modifies the receiver
}
c := Counter{}
c.Increment() // *(&c).Increment(); n becomes 1
fmt.Println(c.Get()) // 1
The conventional discipline:
- Use a pointer receiver if the method modifies the receiver.
- Use a pointer receiver for large structs (avoiding the copy).
- Use a value receiver for small, immutable values (basic types, simple structs).
- Be consistent within a type — all methods on one type conventionally use the same receiver style.
// Inconsistent (avoid):
type T struct{}
func (t T) A() {}
func (t *T) B() {}
// Consistent (preferred):
type T struct{}
func (t *T) A() {}
func (t *T) B() {}
Treated in Methods and interfaces.
No pointer arithmetic
Go does not admit pointer arithmetic:
arr := [5]int{1, 2, 3, 4, 5}
p := &arr[0]
// p++ // ERROR: cannot increment pointer
// p + 1 // ERROR: invalid operation
The mechanism eliminates a substantial class of memory-safety bugs that C admits. For sequential access, slices are the conventional alternative:
arr := [5]int{1, 2, 3, 4, 5}
s := arr[:] // slice; admits indexing and iteration
for i, v := range s {
fmt.Printf("[%d] = %d\n", i, v)
}
The unsafe package admits low-level pointer operations for FFI and performance-sensitive code; treated below.
Pointer comparison
Pointers admit comparison with == and != (equal/not-equal); they cannot be compared with <, >, etc.:
a := 5
b := 5
pa := &a
pb := &b
fmt.Println(pa == pb) // false (different addresses)
fmt.Println(*pa == *pb) // true (equal values)
p := pa
fmt.Println(pa == p) // true (same address)
The form admits comparing whether two pointers refer to the same memory location.
The nil interface vs nil pointer
A subtle point: a nil interface is not the same as an interface holding a nil concrete value:
type MyError struct{}
func (e *MyError) Error() string { return "oops" }
var p *MyError // p == nil
var e error = p // e is an interface holding (*MyError)(nil)
fmt.Println(e == nil) // false! the interface is non-nil
The pitfall has bitten many Go programmers. The defence: return nil directly rather than a typed nil:
func compute() error {
var p *MyError
if /* error condition */ {
p = &MyError{}
}
if p == nil {
return nil // return nil directly
}
return p
}
The unsafe package
The unsafe package admits low-level pointer operations:
import "unsafe"
x := int64(0x12345678)
p := unsafe.Pointer(&x) // generic pointer
fmt.Println(*(*int32)(p)) // 0x5678 on little-endian (the low 32 bits)
// Pointer arithmetic (unsafe.Pointer + uintptr):
type Header struct {
A int32
B int32
}
h := &Header{A: 1, B: 2}
ap := unsafe.Pointer(h)
bp := unsafe.Pointer(uintptr(ap) + unsafe.Offsetof(h.B))
fmt.Println(*(*int32)(bp)) // 2
The principal uses are FFI, low-level data manipulation, and performance-critical code. The unsafe package is unsafe — it bypasses Go’s type and memory safety; the conventional discipline is to avoid it unless genuinely necessary.
Since Go 1.17, unsafe.Slice and unsafe.Add admit safer pointer arithmetic forms; since Go 1.20, unsafe.SliceData and unsafe.String admit additional conversions.
Common patterns
Optional fields
type User struct {
Name string
Age *int // nil means "not specified"
NicknamePtr *string
}
func ageOrZero(u User) int {
if u.Age == nil {
return 0
}
return *u.Age
}
The pattern admits distinguishing “not set” from “set to the zero value”; conventionally used for JSON-deserialised data where presence matters.
For fields that are commonly absent, *T is the conventional encoding.
Modifying through a pointer
type Counter struct { n int }
func (c *Counter) Add(delta int) {
c.n += delta
}
c := &Counter{}
c.Add(5)
c.Add(3)
fmt.Println(c.n) // 8
Constructor pattern
type Server struct {
addr string
handlers map[string]Handler
started bool
}
func NewServer(addr string) *Server {
return &Server{
addr: addr,
handlers: make(map[string]Handler),
}
}
s := NewServer("localhost:8080")
The conventional NewT factory function returns a pointer to a fresh instance.
Avoiding copies
type Document struct {
/* large fields */
}
// Process by pointer to avoid copying:
func (d *Document) Process() error { /* ... */ }
func processAll(docs []*Document) {
for _, d := range docs { // d is *Document; cheap
d.Process()
}
}
Linked list
type Node struct {
value int
next *Node
}
func (n *Node) Append(v int) *Node {
return &Node{value: v, next: n}
}
head := (*Node)(nil)
for i := 1; i <= 5; i++ {
head = head.Append(i)
}
for n := head; n != nil; n = n.next {
fmt.Println(n.value)
}
The pattern admits singly-linked lists and similar recursive structures.
”Maybe” through pointer
func find(items []Item, id int) *Item {
for i := range items {
if items[i].ID == id {
return &items[i] // pointer into the slice
}
}
return nil // not found
}
if item := find(items, 42); item != nil {
process(item)
} else {
fmt.Println("not found")
}
The pattern admits a “maybe” return without the overhead of an option type.
A note on map and slice “pointers”
Maps and slices are already reference-like; passing them to functions admits modification of the underlying data:
func appendOne(s []int) {
s = append(s, 1) // local s changed; caller's s unchanged
}
func mutate(s []int) {
if len(s) > 0 {
s[0] = 999 // caller's slice IS mutated
}
}
s := []int{1, 2, 3}
appendOne(s) // caller's len still 3
mutate(s) // caller's s[0] is now 999
m := map[string]int{"a": 1}
func addEntry(m map[string]int) {
m["b"] = 2 // caller's map IS mutated
}
The subtlety: slices share the backing array (so element mutation is visible) but are themselves a value (so length changes are not visible to the caller). For “modify the slice itself”, pass *[]T:
func appendOne(s *[]int) {
*s = append(*s, 1) // caller sees the new length
}
The form is rare in idiomatic Go; conventionally, return the new slice:
func withOne(s []int) []int {
return append(s, 1)
}
s = withOne(s)
A note on the conventional discipline
The contemporary Go pointer advice:
- Use pointers for modification — the principal motivation.
- Use pointers for large structs — avoid the copy.
- Use value receivers for small, immutable values.
- Be consistent within a type — all methods use the same receiver style.
- Use
&T{...}for pointer-to-struct construction. - Trust the garbage collector — returning pointers to locals is safe.
- Use
nilfor “absent” — it’s the conventional zero value. - Beware nil interface vs typed nil — return
nildirectly. - Avoid
unsafeunless genuinely necessary.
The combination — explicit & and * operators, no pointer arithmetic, garbage collection, escape analysis, value semantics with explicit pointer overrides — is the substance of Go’s memory model. The discipline produces clear, safe code with explicit choices about ownership and modification.