Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 009 / 100

Go pointers explained: a practical guide for developers

Pointers without the fear.

Expona Admin7 min read

Understanding addresses and dereferencing

Pointers are one of the most misunderstood features in Go, yet they're essential for writing efficient production code. The confusion usually stems from how other languages handle memory differently, or from overly theoretical explanations. In this article, we'll cover Go pointers explained: a practical guide for developers working on real systems where performance and correctness matter.

At their core, pointers are simple: they hold memory addresses. The & operator takes the address of a variable, and the * operator dereferences a pointer to access the value at that address. That's it. Everything else is applying these two concepts to solve real problems.

go
package main

import "fmt"

func main() {
    name := "Alice"
    
    // & takes the address
    ptr := &name
    fmt.Printf("Address: %v\n", ptr)
    fmt.Printf("Type: %T\n", ptr)
    
    // * dereferences to get the value
    fmt.Printf("Value: %v\n", *ptr)
    
    // Modify through the pointer
    *ptr = "Bob"
    fmt.Printf("name is now: %v\n", name)
}

When you print a pointer, you see something like 0xc0000160d0—that's a memory address. The type of the pointer tells you what kind of value lives at that address. A *string pointer points to a string; a *int points to an integer.

Why Go has no pointer arithmetic

Unlike C or C++, Go deliberately removed pointer arithmetic. You cannot do ptr++ or ptr + 4 to walk through memory. This design choice makes Go safer and reduces entire categories of buffer overflow bugs.

This constraint forces you to be explicit about what you're doing. If you need to work with a collection of values, use a slice or array, not manual pointer math. Your code becomes clearer, and the compiler can catch mistakes earlier.

go
package main

func main() {
    // This is how you'd work with multiple values
    numbers := []int{10, 20, 30, 40}
    
    // Get a pointer to the first element
    ptr := &numbers[0]
    fmt.Printf("First: %v\n", *ptr)
    
    // Slice gives you range-based iteration instead
    for i, v := range numbers {
        fmt.Printf("Index %d: %v\n", i, v)
    }
    
    // This does NOT work in Go (compiler error):
    // ptr++ // invalid operation
}

In production systems, we've found this limitation actually reduces debugging time. When something goes wrong with memory, the problem space is smaller. You're not hunting through pointer arithmetic chains.

Using pointers to share and mutate state

The practical value of pointers emerges when you need multiple parts of your code to see changes to the same data. This is where pointers become essential in production APIs.

Consider a user struct in an e-commerce system. When you fetch a user from the database, you create a value. If you pass it to functions by value (the default), each function gets its own copy. Changes don't propagate. With a pointer, all functions reference the same underlying data.

go
package main

import "fmt"

type User struct {
    ID    int
    Email string
    Tier  string
}

// Receiver by value: changes are local
func (u User) UpgradeTierValue() {
    u.Tier = "premium"
}

// Receiver by pointer: changes affect the original
func (u *User) UpgradeTierPointer() {
    u.Tier = "premium"
}

func main() {
    user := User{ID: 1, Email: "alice@example.com", Tier: "free"}
    
    user.UpgradeTierValue()
    fmt.Println(user.Tier) // Still "free"
    
    user.UpgradeTierPointer()
    fmt.Println(user.Tier) // Now "premium"
}

This distinction matters when you're updating database records, modifying configuration objects, or managing state across your application. In a CRM system, you load a customer, apply discount rules through multiple functions, and those changes need to accumulate. Pointer receivers ensure that happens.

Another common pattern: dependency injection. You pass a logger, database connection, or cache client to handlers. These are always pointers because you want the original object, not copies.

go
type RequestHandler struct {
    db     *postgres.DB
    cache  *redis.Client
    logger *log.Logger
}

func (h *RequestHandler) HandleOrder(orderID int) error {
    // All three pointers reference the same connections
    order, err := h.db.GetOrder(orderID)
    if err != nil {
        h.logger.Printf("Error: %v", err)
        return err
    }
    
    cached := h.cache.Get(fmt.Sprintf("order:%d", orderID))
    // ...
    return nil
}

Small structs can be passed by value

Not every struct needs a pointer receiver. Go's philosophy is pragmatic: measure and decide based on your actual data.

Small structs—typically under 128 bytes—are often cheaper to copy than to dereference a pointer. A struct with two strings and three integers is small. Copying it is a single memory operation. Dereferencing a pointer adds an indirection that modern CPUs aren't always happy about (cache misses, pipeline stalls).

go
// Small struct: fine to pass by value
type Point struct {
    X, Y float64
}

func (p Point) Distance() float64 {
    return math.Sqrt(p.X*p.X + p.Y*p.Y)
}

// Large struct: consider a pointer
type Order struct {
    ID          string
    UserID      int
    Items       []OrderItem
    Metadata    map[string]interface{}
    CreatedAt   time.Time
    UpdatedAt   time.Time
    // ... many more fields
}

func (o *Order) Process() error {
    // Pointer receiver makes sense here
    return nil
}

In practice, use these heuristics: if your method modifies the receiver, use a pointer. If your struct contains slices or maps that might grow, use a pointer. If it's a small data carrier (like a coordinate or a simple configuration), value receivers are fine. When in doubt, start with a pointer receiver and benchmark if performance becomes a concern.

Common mistakes

Mistake 1: Confused semantics when passing to functions

Developers often pass a pointer when they mean to mutate, but forget that the mutation won't show outside the function unless the receiver is a pointer.

go
func UpdateUser(u User) {  // Wrong: u is a copy
    u.Email = "newemail@example.com"
}

func UpdateUserCorrect(u *User) {  // Right: u is the original
    u.Email = "newemail@example.com"
}

Mistake 2: Nil pointer dereferences

A pointer can be nil. Dereferencing it causes a panic. Always check before dereferencing unless you're certain the pointer is valid.

go
var ptr *User  // ptr is nil

// This panics:
// ptr.Email = "test@example.com"

// Always check:
if ptr != nil {
    ptr.Email = "test@example.com"
}

Mistake 3: Taking the address of a loop variable

When iterating with pointers, the loop variable is reused. Taking its address repeatedly gives you pointers to the same memory location.

go
users := []User{{ID: 1}, {ID: 2}, {ID: 3}}
ptrs := []*User{}

// Wrong: all pointers point to the same final value
for _, u := range users {
    ptrs = append(ptrs, &u)
}

// Right: copy the value first
for _, u := range users {
    u := u  // Create a new variable in this iteration
    ptrs = append(ptrs, &u)
}

// Or: point to the slice element directly
for i := range users {
    ptrs = append(ptrs, &users[i])
}

Key takeaways

  • Pointers are addresses: & gets the address, * dereferences it. That's the foundation.
  • No pointer arithmetic: Go removed it intentionally. Use slices and arrays instead. Your code is safer and clearer.
  • Share and mutate with pointers: Pass a pointer when you need the original object to be modified or when multiple parts of your code must reference the same data.
  • Value receivers for small data: Small structs are often cheaper to copy than to dereference. Let performance guide your choice, not dogma.
  • Always check for nil: Dereferencing a nil pointer panics. Defensive programming pays off in production.
  • Be consistent: If a type has any pointer receivers, make all its methods pointer receivers. The Go community values this consistency.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles