Go pointers explained without the drama

Go pointers with real examples: mutability, methods with receivers, performance, nil and common mistakes. No complications.

Cover for Go pointers explained without the drama

If you come from Python or Java, you’ve been working with references for years without thinking about it. Every time you pass an object to a function, you’re passing a reference to the original object. Whatever you modify inside, gets modified outside. No conscious decision required: the language does it for you.

Go forces you to be explicit. And that, far from being a problem, is one of the best design decisions of the language. When you see *User in a signature, you immediately know that function can modify the original struct. When you see User by itself, you know it works with a copy. No ambiguities, no hidden magic.

Pointers in Go are not C pointers. There’s no pointer arithmetic, no malloc, no free. They are a concrete tool to control when you want to share a reference and when you prefer to work with an independent copy.


What a pointer is: * and & in 2 minutes

A pointer is a variable that stores the memory address of another variable. In Go, two operators are used:

  • & — gets the address of a variable (creates a pointer to it).
  • * — accesses the value a pointer points to (dereference).
package main

import "fmt"

func main() {
    name := "Roger"
    pointer := &name // pointer is of type *string

    fmt.Println(name)    // "Roger"
    fmt.Println(pointer)   // 0xc0000140a0 (memory address)
    fmt.Println(*pointer)  // "Roger" (dereference)

    *pointer = "Another name"
    fmt.Println(name)    // "Another name" — we modified the original
}

The type of pointer in this case is *string. The asterisk in front of the type indicates it’s a pointer to that type. There’s nothing more to understand here: & to get the address, * to read or write what’s at that address.

Explicit declaration

You can also declare a pointer without initializing it:

var p *int // pointer to int, default value: nil
fmt.Println(p) // <nil>

An uninitialized pointer is nil. Accessing *p when p is nil causes a panic. We’ll come back to this later.


Why Go uses pointers: value semantics by default

In most languages you use daily, objects are passed by reference. In Go, everything is passed by value. When you call a function with a struct, Go copies the entire struct.

type Config struct {
    MaxRetries int
    Timeout    int
}

func doubleTimeout(c Config) {
    c.Timeout *= 2
}

func main() {
    cfg := Config{MaxRetries: 3, Timeout: 30}
    doubleTimeout(cfg)
    fmt.Println(cfg.Timeout) // 30 — hasn't changed
}

The doubleTimeout function receives a copy of cfg. It modifies the copy, the original stays intact. If you want the function to modify the original, you need a pointer:

func doubleTimeout(c *Config) {
    c.Timeout *= 2
}

func main() {
    cfg := Config{MaxRetries: 3, Timeout: 30}
    doubleTimeout(&cfg)
    fmt.Println(cfg.Timeout) // 60 — now it works
}

This value-by-default semantics is deliberate. It forces you to make the conscious decision of when something is mutable from outside. In a large project, that translates to fewer bugs from unexpected mutation.


Value receivers vs pointer receivers

When you define methods on a struct, you have to choose between a value receiver and a pointer receiver. The difference is fundamental and directly affects how your code behaves.

Value receiver

type Rectangle struct {
    Width  float64
    Height float64
}

func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

The Area method receives a copy of the Rectangle. It cannot modify it, only read it. Perfect for methods that calculate something without changing state.

Pointer receiver

func (r *Rectangle) Scale(factor float64) {
    r.Width *= factor
    r.Height *= factor
}

The Scale method receives a pointer. It modifies the original struct. If you used a value receiver here, the changes would be lost.

The practical rule

The convention in Go is clear:

  1. If the method modifies the receiver → pointer receiver. No discussion.
  2. If the struct is large → pointer receiver, to avoid unnecessary copies.
  3. If any method of the type uses a pointer receiver → all should use pointer receiver, for consistency.
  4. If the struct is small and immutable → value receiver works fine.
// Consistency: if one uses pointer receiver, all should
type User struct {
    ID     int
    Name   string
    Email  string
    Config ComplexConfig
}

func (u *User) ChangeEmail(new string) {
    u.Email = new
}

func (u *User) FullName() string {
    return u.Name // Even though it doesn't modify, we use pointer receiver for consistency
}

One detail that sometimes causes confusion: Go allows you to call methods with pointer receivers on a value (non-pointer) and vice versa. The compiler does the conversion automatically:

rect := Rectangle{Width: 10, Height: 5}
rect.Scale(2) // Go automatically converts to (&rect).Scale(2)

But this only works when the compiler can take the address of the variable. It doesn’t work with values returned directly from functions if they’re not stored in a variable first.


When to use pointers: mutability, large structs, shared state

Not all scenarios need pointers. Here are the three clear cases where you do need them.

1. Mutability

The most obvious case. If a function or method needs to modify an argument, you need a pointer.

func resetCounter(c *Counter) {
    c.Value = 0
    c.LastReset = time.Now()
}

2. Large structs

Copying a struct with 3 fields is cheap. Copying one with 20 fields, internal slices and maps is not. For large structs, a pointer avoids the copy:

type FullReport struct {
    Title     string
    Sections  []Section
    Metadata  map[string]string
    Content   []byte
    History   []Revision
    // ... 15 more fields
}

// Pointer to avoid copying the entire structure
func processReport(report *FullReport) error {
    // ...
    return nil
}

3. Shared state

When multiple goroutines or components need to access and modify the same data:

type Cache struct {
    mu   sync.RWMutex
    data map[string]string
}

func NewCache() *Cache {
    return &Cache{
        data: make(map[string]string),
    }
}

func (c *Cache) Set(key, value string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.data[key] = value
}

func (c *Cache) Get(key string) (string, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    val, ok := c.data[key]
    return val, ok
}

Here, Cache is always passed as a pointer. Copying a struct with a sync.Mutex inside is a guaranteed bug — the copy would have its own independent mutex, breaking all synchronization.

When NOT to use pointers

  • Basic types (int, string, bool): copy them without fear. They’re small and the copy is practically free.
  • Small, immutable structs: if it only has 2-3 fields and no method modifies it, a copy is simpler and safer.
  • When you want to guarantee immutability: passing a copy ensures nobody modifies the original from somewhere else.

Nil pointers: the billion-dollar mistake (and how Go mitigates it)

Tony Hoare called the null reference his “billion-dollar mistake.” Go doesn’t eliminate the problem — nil pointers exist — but it gives you tools to mitigate it.

The classic panic

var u *User
fmt.Println(u.Name) // panic: runtime error: invalid memory address or nil pointer dereference

This is probably the most common panic in Go. Trying to access a field or method of a nil pointer crashes at runtime.

How to protect yourself

1. Check for nil before using:

func sendNotification(u *User) error {
    if u == nil {
        return fmt.Errorf("user is nil")
    }
    // proceed safely
    return sendEmail(u.Email)
}

2. Return errors instead of nil pointers without context:

// Bad: the caller doesn't know why it's nil
func findUser(id int) *User {
    // ...
    return nil
}

// Good: the caller knows exactly what happened
func findUser(id int) (*User, error) {
    u, err := db.Query(id)
    if err != nil {
        return nil, fmt.Errorf("error finding user %d: %w", id, err)
    }
    if u == nil {
        return nil, fmt.Errorf("user %d not found", id)
    }
    return u, nil
}

The (*T, error) pattern is idiomatic in Go and is your main defense against unexpected nils. If your function can fail, always return an error. Always.

3. Use the zero value to your advantage:

In many cases, you can avoid pointers entirely by returning the struct’s zero value along with an error:

func findConfig(name string) (Config, error) {
    cfg, ok := configs[name]
    if !ok {
        return Config{}, fmt.Errorf("config %q not found", name)
    }
    return cfg, nil
}

No pointer here, no risk of nil. The caller receives a copy or a zero value. Simpler, safer.


Pointers and function arguments

When you pass a pointer to a function, the function receives a copy of the pointer (which points to the same memory address). This has important implications.

Modifying what’s pointed to works

func changeName(u *User) {
    u.Name = "New" // modifies the original struct
}

Reassigning the pointer doesn’t affect the caller

func replace(u *User) {
    u = &User{Name: "Other"} // only changes the local copy of the pointer
}

func main() {
    u := &User{Name: "Roger"}
    replace(u)
    fmt.Println(u.Name) // "Roger" — hasn't changed
}

Inside replace, u is a local variable that contains a copy of the pointer. Reassigning it doesn’t affect the original pointer in main. It only affects the local variable.

Slices, maps and channels: already references

Something that causes confusion: slices, maps and channels already internally contain a pointer to the underlying data. Passing them to a function doesn’t copy the data, only the slice/map/channel header.

func addElement(s []int) []int {
    return append(s, 42)
}

func modifyElement(s []int) {
    s[0] = 999 // modifies the original underlying array
}

You don’t need *[]int to modify the elements of a slice. But you do need it if you want the function to change the slice itself (its length or capacity) in a way visible to the caller — that’s why append returns the new slice instead of modifying the original directly.


Pointers to pointers: when you need them (almost never)

A pointer to a pointer (**T) is exactly what it sounds like: a pointer that points to another pointer. In Go, it’s extremely rare to need them.

The legitimate case

The only scenario where it makes sense is when a function needs to reassign the caller’s pointer:

func initializeIfNil(pp **Config) {
    if *pp == nil {
        *pp = &Config{
            MaxRetries: 3,
            Timeout:    30,
        }
    }
}

func main() {
    var cfg *Config // nil
    initializeIfNil(&cfg)
    fmt.Println(cfg.Timeout) // 30
}

Why you almost never need it

In practice, there are clearer ways to solve this:

// Better: return the pointer directly
func initializeIfNil(cfg *Config) *Config {
    if cfg == nil {
        return &Config{
            MaxRetries: 3,
            Timeout:    30,
        }
    }
    return cfg
}

func main() {
    var cfg *Config
    cfg = initializeIfNil(cfg)
    fmt.Println(cfg.Timeout) // 30
}

This pattern is much more readable and doesn’t require double pointers. If you find yourself writing **T in Go, stop and think whether there’s a simpler alternative. There almost always is.


Common mistakes with pointers

1. Pointer to loop variable

This is a classic that has caught many people off guard. In versions of Go prior to 1.22, the loop variable was reused in each iteration:

// BUG in Go < 1.22
names := []string{"Ana", "Luis", "Marta"}
pointers := make([]*string, len(names))

for i, name := range names {
    pointers[i] = &name // all point to the same variable!
}

for _, p := range pointers {
    fmt.Println(*p) // "Marta", "Marta", "Marta"
}

The name variable was reused in each iteration, so all pointers pointed to the same address. At the end of the loop, that address contains “Marta”.

Classic solution (pre Go 1.22):

for i, name := range names {
    n := name // local copy
    pointers[i] = &n
}

In Go 1.22+ this problem is solved: each iteration creates a new variable. But if you maintain code that compiles with earlier versions, it’s still relevant.

2. Nil dereference in access chains

type Company struct {
    Director *Person
}

type Person struct {
    Address *Address
}

type Address struct {
    City string
}

func getCity(c *Company) string {
    // PANIC if c, c.Director or c.Director.Address are nil
    return c.Director.Address.City
}

Each pointer access in the chain can cause a panic. The solution is to check each level:

func getCity(c *Company) string {
    if c == nil || c.Director == nil || c.Director.Address == nil {
        return ""
    }
    return c.Director.Address.City
}

Yes, it’s verbose. But it’s explicit. Go prefers verbosity over magic that hides runtime explosions.

3. Copying a struct with a mutex or internal resources

type Pool struct {
    mu    sync.Mutex
    conns []*Connection
}

func doSomething(p Pool) { // BUG: copies the mutex
    p.mu.Lock()
    defer p.mu.Unlock()
    // ...
}

Copying a sync.Mutex creates an independent mutex. Both copies can be locked simultaneously, which is exactly the opposite of what you wanted. Always pass structs with mutexes as pointers. The compiler won’t warn you about this — you need to know it.

If you use go vet, it will detect some of these cases, but not all. Testing with -race also helps find these problems.


Performance: when copying is fine and when pointers help

There’s a widespread idea that “pointers = faster.” That’s not always true.

When copying is better

  • Small structs (< ~64 bytes, 3-4 fields): the copy is so fast that the pointer indirection overhead can actually be worse.
  • Read-only data: copies are cache-friendly. The processor can access contiguous data in memory faster than chasing pointers.
  • Concurrency: each goroutine with its own copy doesn’t need synchronization.

When pointers help

  • Large structs: copying 1 KB of data on every function call does have a cost.
  • Shared data: when multiple goroutines need to read and write the same state.
  • Interfaces: when you assign a large struct to an interface, Go needs to make a copy. A pointer avoids that copy.

Measure, don’t assume

Go gives you built-in benchmark tools. If you’re unsure about performance impact, measure:

func BenchmarkValue(b *testing.B) {
    cfg := Config{MaxRetries: 3, Timeout: 30}
    for i := 0; i < b.N; i++ {
        processByValue(cfg)
    }
}

func BenchmarkPointer(b *testing.B) {
    cfg := &Config{MaxRetries: 3, Timeout: 30}
    for i := 0; i < b.N; i++ {
        processByPointer(cfg)
    }
}

For small structs, the difference will be statistical noise. For structs of hundreds of bytes, the difference will be measurable. The rule: don’t optimize before measuring, and don’t use pointers “for performance” without data to back it up.

Escape analysis

Go decides at compile time whether a variable stays on the stack or moves to the heap. When you take the address of a local variable and return it as a pointer, that variable “escapes” to the heap:

func createUser() *User {
    u := User{Name: "Roger"} // escapes to heap because we return its address
    return &u
}

Heap allocations are more expensive than stack allocations and generate work for the garbage collector. You can see the compiler’s decisions with:

go build -gcflags="-m" ./...

This doesn’t mean you should avoid returning pointers. It means you should be aware that it’s not free, and that sometimes returning a value (copy) is more efficient than returning a pointer that forces a heap allocation.


Decision guide: when to use pointers

After years of working with Go, this is the checklist I run through mentally before deciding whether a parameter or receiver should be a pointer:

Use a pointer when:

  • The method or function needs to modify the argument.
  • The struct contains a sync.Mutex or other fields that shouldn’t be copied.
  • The struct is large (> 5-6 fields or contains heavy slices/maps).
  • You need to represent the absence of a value (nil).
  • Multiple goroutines need to access the same data.
  • Some other method of the type already uses a pointer receiver (consistency).

Use a value when:

  • The struct is small and immutable.
  • You want to guarantee nobody modifies the original.
  • It’s a basic type (int, string, bool, float64).
  • It’s a slice, map or channel (they already contain internal references).
  • It’s a pure function with no side effects.

Golden rule: start with values. Switch to pointers when you have a concrete reason. If the reason is “for performance” and you haven’t done a benchmark, you probably don’t need the pointer.


The default is value, and that’s a good thing

Pointers in Go are not the monster they seem if you come from languages with garbage collection and automatic pass-by-reference. They are an explicit tool that gives you control over something other languages hide: when you’re working with a copy and when you’re working with the original reference.

Go’s value-by-default semantics is, in my opinion, one of its greatest strengths. It forces you to think about mutability for every function, every method, every struct. That makes code more predictable and easier to reason about, especially in projects with concurrency.

Don’t convert everything to pointers by default. Don’t avoid pointers out of fear. Use the rules in this guide, measure when in doubt, and write code that anyone on the team can read and understand without needing to trace hidden mutations.

If you want to deepen your understanding of the foundations, start by understanding structs in Go and how error handling works — both topics are directly connected to the decisions you make about pointers every day.

OshyTech

Backend and data engineering focused on scalable systems, automation, and AI.

Navigation

Copyright 2026 OshyTech. All Rights Reserved