Joseph Kimenyu

Go Pointers: Stop Copying Everything

August 7, 2025
6 min read
46 views

If you're coming from Python or JavaScript, Go pointers will feel weird at first. You're not used to thinking about where data lives in memory, and why would you be? Those languages handle it for you.

Go doesn't. And honestly, once it clicks, you'll appreciate why.


What Even Is a Pointer

A pointer is just a variable that holds a memory address. Instead of holding a value like 42 or "hello", it holds the location of where that value lives.

Go gives you two operators for this:

  • & gives you the address of a variable
  • * gives you the value at an address (dereferencing)
package main

import "fmt"

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

    fmt.Println(age)  // 25
    fmt.Println(p)    // 0xc000014088 (some address)
    fmt.Println(*p)   // 25

    *p = 30
    fmt.Println(age)  // 30 -- age changed because p points to it
}

That last bit is the key thing. When you modify *p, you're modifying age directly. They're the same memory location.


Why Not Just Copy Everything

You could. And for small values like ints and booleans, that's fine. Go copies those by default and it's fast.

The problem starts when you have a big struct and you're passing it around to multiple functions. Each function call makes a full copy of that struct. If it's large, that adds up.

type UserProfile struct {
    ID        int
    Name      string
    Email     string
    Bio       string
    AvatarURL string
    Settings  map[string]interface{}
}

// this copies the entire struct on every call
func printProfile(u UserProfile) {
    fmt.Println(u.Name)
}

// this just passes an 8-byte address
func printProfilePtr(u *UserProfile) {
    fmt.Println(u.Name)
}

The second function doesn't care how big UserProfile is. It always gets an 8-byte pointer.


Pointers Let Functions Actually Modify Your Data

This one trips people up. In Go, function arguments are copied. So if you pass a struct to a function and the function changes it, you won't see those changes outside.

type Counter struct {
    Value int
}

func increment(c Counter) {
    c.Value++ // modifies the copy, not the original
}

func incrementPtr(c *Counter) {
    c.Value++ // modifies the original
}

func main() {
    c := Counter{Value: 0}

    increment(c)
    fmt.Println(c.Value) // 0 -- unchanged

    incrementPtr(&c)
    fmt.Println(c.Value) // 1 -- changed
}

If you've ever wondered why your struct wasn't updating after passing it to a function, this is probably why.


The new Keyword

Go has a built-in new function that allocates memory for a type and returns a pointer to it. The value is zeroed out.

func main() {
    p := new(int)
    fmt.Println(*p) // 0

    *p = 100
    fmt.Println(*p) // 100
}

Honestly, new isn't that common in real Go code. Most of the time you're using & with a struct literal, which is cleaner:

type Config struct {
    Port    int
    Host    string
    Debug   bool
}

cfg := &Config{
    Port:  8080,
    Host:  "localhost",
    Debug: true,
}

fmt.Println(cfg.Port) // Go auto-dereferences struct fields, no need for (*cfg).Port

Go auto-dereferences struct fields, so cfg.Port works the same as (*cfg).Port. You'll write the former basically always.


Pointer Receivers on Methods

This is where pointers become really important in day-to-day Go. When you define methods on a struct, you choose between a value receiver and a pointer receiver.

type Wallet struct {
    Balance float64
}

// value receiver -- works on a copy
func (w Wallet) GetBalance() float64 {
    return w.Balance
}

// pointer receiver -- works on the original
func (w *Wallet) Deposit(amount float64) {
    w.Balance += amount
}

func main() {
    w := Wallet{Balance: 1000}
    w.Deposit(500)
    fmt.Println(w.GetBalance()) // 1500
}

The rule is straightforward: if the method needs to modify the struct, use a pointer receiver. If it's just reading, a value receiver is fine. But practically, most people just use pointer receivers across the board on a given type for consistency.


nil Pointers Will Crash Your Program

A pointer that hasn't been assigned a value is nil. Dereferencing a nil pointer panics at runtime.

var p *int
fmt.Println(*p) // panic: runtime error: invalid memory address or nil pointer dereference

This is probably the most common source of crashes in Go. Always check before dereferencing when there's any chance the pointer could be nil.

func getUserName(id int) *string {
    // maybe the user doesn't exist
    if id == 0 {
        return nil
    }
    name := "Joseph"
    return &name
}

func main() {
    name := getUserName(0)

    if name == nil {
        fmt.Println("user not found")
        return
    }

    fmt.Println(*name)
}

When to Use a Pointer vs a Value

Here's a rough mental model:

Use a pointer when:

  • the function or method needs to modify the value
  • the struct is large enough that copying is wasteful
  • you need to represent the absence of a value (nil)
  • you're working with something that has a meaningful identity, like a database connection or a cache

Use a value when:

  • the type is small (int, bool, small struct)
  • you want immutability -- the caller can't modify your original
  • you're working with types like time.Time that are designed to be copied

A Practical Example

Here's something closer to real code. A simple service struct with pointer receivers and a constructor that returns a pointer:

package main

import (
    "errors"
    "fmt"
)

type OrderService struct {
    orders map[int]string
    nextID int
}

func NewOrderService() *OrderService {
    return &OrderService{
        orders: make(map[int]string),
        nextID: 1,
    }
}

func (s *OrderService) Create(item string) int {
    id := s.nextID
    s.orders[id] = item
    s.nextID++
    return id
}

func (s *OrderService) Get(id int) (string, error) {
    item, ok := s.orders[id]
    if !ok {
        return "", errors.New("order not found")
    }
    return item, nil
}

func main() {
    svc := NewOrderService()

    id := svc.Create("Laptop")
    fmt.Println("created order:", id)

    item, err := svc.Get(id)
    if err != nil {
        fmt.Println("error:", err)
        return
    }

    fmt.Println("order item:", item)
}

The constructor returns *OrderService because you want everyone using this service to share the same underlying data. If you returned a value, each caller would get their own copy with their own empty map. That's not what you want.


Wrapping Up

Pointers in Go aren't complicated once you build the right mental model. They're addresses. They let you share data across function calls, avoid expensive copies, and represent the absence of a value with nil.

The two things that will catch you: forgetting that function arguments are copied (so pass a pointer if you want mutations to stick), and dereferencing a nil pointer without checking first.

Get those two right and pointers stop being a source of bugs and start being a useful tool.