Pointers in Go

A pointer is a variable that stores the memory address of another variable instead of storing a value directly. Go gives you pointers so a function can modify the caller’s data, so you can avoid copying large structs on every function call, and so you can build data structures like linked lists and trees — all without the manual memory management or raw pointer arithmetic that make pointers so dangerous in languages like C. Because Go pointers are garbage-collected and cannot be moved around with arithmetic, they are far safer to use than their C counterparts while still giving you precise control over sharing and mutation.

Overview / How it works

Every variable in a running program lives somewhere in memory. Normally you interact with a variable by name, and the compiler translates that name into a memory address behind the scenes. A pointer makes that address explicit and lets you store it in its own variable. Go has two operators for working with addresses:

  • & (address-of) takes a variable and returns a pointer to it — the memory address where that variable lives.
  • * (dereference), when applied to a pointer, follows the address and gives you the value stored there. The same symbol, written before a type (*int, *MyStruct), means \”a pointer to this type\” rather than an operation.

A pointer’s zero value is nil, meaning it points to nothing. Declaring var p *int gives you a pointer that is not yet pointing anywhere; you must assign it an address (with & or new) before dereferencing it, or the program will panic.

Unlike C, Go does not let you do pointer arithmetic — you cannot add 1 to a pointer to \”walk\” to the next memory cell. This single restriction removes an entire class of memory-corruption bugs while keeping pointers useful for sharing and mutation. Go also has no manual free(): the garbage collector tracks which memory is still reachable through some pointer and reclaims the rest automatically, so dangling pointers (pointers to memory that has already been freed) simply cannot happen the way they can in C.

Where does the pointed-to memory actually live? The Go compiler performs escape analysis at compile time to decide. If a value’s address never escapes the function it was created in, the compiler can safely allocate it on the stack, which is fast to allocate and free. But if you return a pointer to a local variable, or store it somewhere that outlives the function (a global, a channel, a returned struct), the compiler detects that the value \”escapes\” and allocates it on the heap instead, where the garbage collector can manage its lifetime. This is why writing return &localVar is completely safe in Go, even though the equivalent in C (returning a pointer to a stack variable) is undefined behavior — Go’s compiler simply moves the variable to the heap for you.

Syntax

The general forms you will use constantly are shown below.

var p *T        // declare a pointer to type T (zero value: nil)
p = &v          // p now holds the address of v
*p              // dereference: read the value v points to
*p = newValue   // dereference and assign: sets v to newValue
new(T)          // allocates a zeroed T and returns *T
Syntax Meaning
&x Address-of operator: produces a *T pointing at x.
*p Dereference operator: the value p points to.
*T Type: \”pointer to T\”, used in variable, parameter, and return declarations.
new(T) Allocates memory for a zeroed value of type T and returns a *T.
nil The zero value for any pointer type; means \”points to nothing\”.

Examples

Example 1: Address-of and dereference

package main

import "fmt"

func main() {
	age := 30
	p := &age

	fmt.Println("age:", age)
	fmt.Println("*p:", *p)

	*p = 31
	fmt.Println("age after *p = 31:", age)
}

Output:

age: 30
*p: 30
age after *p = 31: 31

p := &age stores the address of age in p. Reading *p follows that address and returns 30, the current value of age. Assigning through the pointer with *p = 31 changes age itself, even though the code never mentions age by name on that line — p and age refer to the same storage location.

Example 2: Pass by value vs. pass by pointer

package main

import "fmt"

func doubleValue(n int) {
	n = n * 2
}

func doublePointer(n *int) {
	*n = *n * 2
}

func main() {
	x := 10
	doubleValue(x)
	fmt.Println("after doubleValue:", x)

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

Output:

after doubleValue: 10
after doublePointer: 20

Go always passes arguments by value — a function receives a copy of whatever you pass it. doubleValue receives a copy of x, doubles the copy, and the original x in main is untouched. doublePointer instead receives a copy of x‘s address. That copy still points at the same underlying int, so dereferencing it with *n reaches and modifies the real x.

Example 3: Pointers to structs and new

package main

import "fmt"

type Counter struct {
	count int
}

func (c *Counter) Increment() {
	c.count++
}

func main() {
	c := &Counter{count: 0}
	c.Increment()
	c.Increment()
	c.Increment()
	fmt.Println("count:", c.count)

	p := new(int)
	*p = 42
	fmt.Println("*p:", *p)
}

Output:

count: 3
*p: 42

c is a *Counter. Notice that the code writes c.count, not (*c).count — Go automatically dereferences pointers to structs when you access a field or call a method, so you rarely need to write the dereference explicitly. Increment uses a pointer receiver (func (c *Counter)), which means each call operates on the original struct rather than a copy, so the increments accumulate. new(int) is a second way to obtain a pointer: it allocates a zeroed int on the heap and returns a pointer to it, equivalent to x := 0; p := &x.

How it works step by step

Take doublePointer(&x) from Example 2 as a walk-through of the mechanism:

  • 1. x := 10 creates a variable and gives it storage — because its address is later taken and passed into another function, escape analysis places it wherever is safe (the compiler decides stack vs. heap; the program behaves identically either way).
  • 2. &x evaluates to the address of that storage, producing a value of type *int.
  • 3. That address is passed as the argument n to doublePointer. The parameter n is a new, local pointer variable, but its value (the address) is identical to &x, so both point at the same storage.
  • 4. Inside the function, *n dereferences n: the CPU follows the stored address, reads the int living there, multiplies it by two, and writes the result back to that same address.
  • 5. When doublePointer returns, its local variable n is gone, but the storage it pointed to is unaffected — x in main now holds 20 because that storage was modified in place.

The garbage collector’s job throughout this process is to track, at any point in time, which heap allocations are still reachable through some chain of pointers from a running goroutine’s stack or from global variables. When nothing points to an allocation anymore, it becomes eligible for collection. You never call free yourself.

Common Mistakes

Mistake 1: Dereferencing a nil pointer

A freshly declared pointer is nil until you assign it an address. Dereferencing a nil pointer compiles fine but panics at runtime:

package main

import "fmt"

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

Output:

panic: runtime error: invalid memory address or nil pointer dereference

Fix it by checking for nil before dereferencing, or by making sure the pointer is initialized (with & or new) before it is ever used:

package main

import "fmt"

func main() {
	var p *int
	if p == nil {
		fmt.Println("p has no value yet")
	} else {
		fmt.Println(*p)
	}
}

Mistake 2: Capturing a loop variable’s address in a goroutine

A classic Go bug is launching a goroutine per loop iteration and passing it the loop variable implicitly instead of explicitly. On Go versions before 1.22, every iteration of a for loop reused the same variable, so all the closures below could observe the loop variable’s final value instead of the value it held during their own iteration:

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	nums := []int{1, 2, 3}

	for _, n := range nums {
		wg.Add(1)
		go func() {
			defer wg.Done()
			fmt.Println(n)
		}()
	}
	wg.Wait()
}

Output: unreliable — on Go versions before 1.22 this often prints the same value three times (e.g. 3 3 3) instead of 1, 2, and 3.

The defensive fix, which works on every Go version and makes the intent explicit, is to pass the loop variable in as a parameter so each goroutine captures its own copy:

package main

import (
	"fmt"
	"sync"
)

func main() {
	nums := []int{1, 2, 3}
	results := make([]int, len(nums))
	var wg sync.WaitGroup

	for i, n := range nums {
		wg.Add(1)
		go func(i, n int) {
			defer wg.Done()
			results[i] = n * n
		}(i, n)
	}

	wg.Wait()
	fmt.Println(results)
}

Output:

[1 4 9]

Each goroutine now receives its own copies of i and n as arguments, and each writes to a distinct slice index, so the result is correct and deterministic regardless of goroutine scheduling order or Go version.

Best Practices

  • Use a pointer receiver when a method needs to mutate the receiver, or when the struct is large enough that copying it on every call would be wasteful.
  • Once a type has any pointer-receiver method, make all of its methods pointer receivers for consistency, even ones that do not mutate anything.
  • Always check err != nil before trusting a returned pointer — many functions return (nil, err) on failure, and dereferencing that nil without checking is a common panic source.
  • Prefer returning values over returning pointers for small, simple types (like int or a small struct) — let the compiler’s escape analysis and Go’s efficient copying handle it, and only reach for a pointer when you specifically need mutation or shared ownership.
  • Don’t take the address of a loop or range variable to store it for later use without passing it explicitly into a closure or function — prefer the explicit-parameter pattern shown above.
  • Remember Go has no pointer arithmetic: you cannot increment a pointer to walk through an array the way you can in C. Use slices and indexing instead.

Practice Exercises

  • Write a function swap(a, b *int) that swaps the values of two integers through their pointers, and call it from main to swap two variables, printing the values before and after.
  • Write a function increment(counter *int) that adds 1 to the int a pointer points to. Call it in a loop five times on the same variable and print the final value (expected output: 5).
  • Define a struct Rectangle with Width and Height fields and a pointer-receiver method Scale(factor int) that multiplies both fields by factor in place. Create a Rectangle, call Scale(2), and print the resulting width and height.

Summary

  • A pointer stores the memory address of another variable; & takes an address, * dereferences a pointer to read or write the value it points to.
  • A pointer’s zero value is nil; dereferencing a nil pointer panics at runtime.
  • Go passes everything by value, so passing a pointer is how a function gains the ability to modify the caller’s data.
  • Go automatically dereferences pointers to structs for field access and method calls, so c.count works even when c is a *Counter.
  • Escape analysis lets the compiler safely return pointers to local variables by promoting them to the heap; the garbage collector reclaims heap memory once nothing points to it anymore, so dangling pointers cannot occur.
  • Go has no pointer arithmetic, which removes a large class of memory-safety bugs found in languages like C.
  • A common real-world bug is capturing a loop variable’s address implicitly in a goroutine closure; pass it as an explicit parameter instead.