Polyglot
Languages Go pointers
Go § pointers

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 nil before dereferencing.
  • Use the language’s nil checks (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 nil for “absent” — it’s the conventional zero value.
  • Beware nil interface vs typed nil — return nil directly.
  • Avoid unsafe unless 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.