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.
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.
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.
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.
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).
// 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.
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.
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.
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.






Comments
Loading comments…