A Guide to Pointers in Go
Let’s get real about pointers in Go—they’re not the scary beasts you might remember from C (if you came from that CS background). Go’s pointer implementation strikes that perfect balance between giving you low-level control and keeping you from shooting yourself in the foot.
For Python Developers: Why Care About Pointers?
This blog introduces pointers, a fundamental programming concept many developers struggle with. Having worked with numerous senior Cloud Engineers, SREs, and Platform Engineers who, despite years of industry experience, have only Python knowledge and limited exposure to memory management concepts, I’ve crafted this primer to establish essential context before diving into the main teachings.
If you’re coming from Python, you might be wondering why we even need pointers. After all, Python handles everything behind the scenes, right? Well, that’s exactly the point - and also the limitation.
In Python, all variables are essentially references to objects. When you pass a variable to a function, you’re passing a reference, but you don’t get explicit control over whether something is passed by reference or by value. This is why you can modify a list inside a function, but not an integer.
Here’s an example:
# In Python, you can change mutable objects in functions
def add_cat(cat_list):
cat_list.append("Felix") # This modifies the original list
cats = ["Whiskers", "Mittens"]
add_cat(cats)
print(cats) # Output: ["Whiskers", "Mittens", "Felix"]
# But you can't change immutable objects
def age_cat(cat_age):
cat_age += 1 # This creates a new local variable
whiskers_age = 3
age_cat(whiskers_age)
print(whiskers_age) # Output: 3 (unchanged!)
Let’s visualize how Python’s implicit references differ from Go’s explicit pointers:
%%{init: {'theme':'dark'}}%% flowchart TD subgraph "Py (Implicit References)" style pyvar fill:#6366F1,stroke:#818CF8,color:#FFFFFF,font-weight:bold style pyobj fill:#EC4899,stroke:#F472B6,color:#FFFFFF,font-weight:bold style pymut fill:#10B981,stroke:#34D399,color:#FFFFFF,font-weight:bold style pyimmut fill:#F43F5E,stroke:#FB7185,color:#FFFFFF,font-weight:bold pyvar["Variables"] pyobj["Objects in Memory"] pymut["Mutable Objects<br>(lists, dicts, etc)"] pyimmut["Immutable Objects<br>(int, str, tuple, etc)"] pyvar -->|"Implicit References"| pyobj pyobj --> pymut & pyimmut pymut -->|"Can be modified<br>through reference"| pymut_mod["✅"] pyimmut -->|"Creates new object<br>when 'modified'"| pyimmut_mod["❌"] end subgraph "Go (Explicit Pointers)" style goval fill:#6366F1,stroke:#818CF8,color:#FFFFFF,font-weight:bold style goptr fill:#10B981,stroke:#34D399,color:#FFFFFF,font-weight:bold style gomem fill:#EC4899,stroke:#F472B6,color:#FFFFFF,font-weight:bold goval["Value Variables<br>(int, string, struct)"] goptr["Pointer Variables<br>(*int, *string, *struct)"] gomem["Memory Locations"] goval -->|"Direct Value"| gomem goptr -->|"Explicit Pointer"| gomem gomem -->|"Modify via pointer"| gomod["✅ Developer's<br>Explicit Choice"] end
So What Are Pointers?
Pointers are variables that store memory addresses. Instead of storing values directly, they point to where those values live in memory. This seemingly simple concept unlocks powerful programming patterns and performance optimizations.
Here’s what a pointer looks like in Go code:
var x int = 42
var p *int = &x // p is a pointer to x
The key symbols to understand:
&
(address-of operator): Gets the memory address of a variable*
(dereference operator): Gets the value stored at that memory address
Let’s visualize the core pointer concept:
%%{init: {'theme':'dark'}}%% flowchart LR style varX fill:#6366F1,stroke:#818CF8,color:#FFFFFF,font-weight:bold style ptrP fill:#10B981,stroke:#34D399,color:#FFFFFF,font-weight:bold style memAddress fill:#F59E0B,stroke:#FBBF24,color:#1F2937,font-weight:bold varX["Variable x (42)"] ptrP["Pointer p (*int)"] memAddress["Memory Address 0x82c10a0"] ptrP -->|"points to"| memAddress memAddress -.->|"stores"| varX
How Go Manages Memory with Pointers
Go’s memory model is worth understanding when working with pointers. Unlike C, Go features automatic garbage collection, which means you don’t need to manually free memory. This prevents many common pointer-related bugs.
Here’s a visualization of Go’s memory layout:
%%{init: {'theme':'dark'}}%% flowchart TD style stack fill:#8B5CF6,stroke:#A78BFA,color:#FFFFFF,font-weight:bold style heap fill:#EC4899,stroke:#F472B6,color:#FFFFFF,font-weight:bold style stackVar fill:#6366F1,stroke:#818CF8,color:#FFFFFF,font-weight:bold style heapObj fill:#F97316,stroke:#FB923C,color:#FFFFFF,font-weight:bold style pointer fill:#10B981,stroke:#34D399,color:#FFFFFF,font-weight:bold stack["Stack Memory"] heap["Heap Memory"] stackVar["Local Variables<br>i := 10<br>p *Person"] heapObj["Objects<br>Person{Name: 'Alice'}"] pointer["Pointer"] stack --- stackVar heap --- heapObj stackVar -- "points to" --> heapObj pointer -.- stackVar
In Go:
- Stack: Fast memory for local variables (automatically cleaned up when function returns)
- Heap: Memory for objects that outlive their creating function
- Garbage Collector: Automatically cleans up unreachable objects in the heap
Setting Up a Go Project for Pointer Experimentation
To follow along with the examples, create a small project structure:
mkdir pointers-demo
cd pointers-demo
go mod init pointers-demo
touch main.go
This creates a minimal Go module where you can experiment with the code examples that follow. We will be editing and running main.go
throughout.
go run main.go
Basic Pointer Examples
Let’s start with simple, practical examples of Go pointers in action.
Example 1: Working with Integer Pointers
package main
import "fmt"
func main() {
// Create an integer variable
count := 42
// Create a pointer to the integer
countPtr := &count
// Print the original value
fmt.Println("Original value:", count)
// Print the pointer (memory address)
fmt.Println("Pointer address:", countPtr)
// Dereference the pointer to get the value
fmt.Println("Dereferenced value:", *countPtr)
// Modify the value through the pointer
*countPtr = 100
// See that the original variable was changed
fmt.Println("New value:", count)
}
This example demonstrates the core operations of pointers:
- Creating a variable (
count
) - Getting its memory address with
&
(countPtr
) - Accessing the value at that address with
*
(*countPtr
) - Modifying the original value through the pointer by assigning to
*countPtr
Let’s visualize what’s happening in memory:
%%{init: {'theme':'dark'}}%% flowchart LR style stack fill:#8B5CF6,stroke:#A78BFA,color:#FFFFFF,font-weight:bold style count fill:#F97316,stroke:#FB923C,color:#FFFFFF,font-weight:bold style countPtr fill:#10B981,stroke:#34D399,color:#FFFFFF,font-weight:bold style memCell fill:#EC4899,stroke:#F472B6,color:#FFFFFF,font-weight:bold stack["Stack Memory"] count["count (int)"] countPtr["countPtr (*int)"] memCell["Memory Cell<br>Initially: 42<br>After: 100"] stack --> count & countPtr countPtr -->|"points to"| memCell count -.->|"direct value"| memCell
Example 2: Working with String Pointers
Pointers work with all types in Go, including strings:
package main
import "fmt"
func main() {
// Create a string variable
message := "Hello, Go!"
// Create a pointer to the string
messagePtr := &message
// Print original string
fmt.Println("Original:", message)
// Modify through pointer
*messagePtr = "Updated via pointer!"
// See the changed string
fmt.Println("Updated:", message)
}
When you run this code, you’ll see that modifying the string through the pointer affects the original variable. This works because the pointer gives direct access to the memory where message
is stored.
The memory visualization:
%%{init: {'theme':'dark'}}%% flowchart LR style message fill:#F97316,stroke:#FB923C,color:#FFFFFF,font-weight:bold style messagePtr fill:#10B981,stroke:#34D399,color:#FFFFFF,font-weight:bold style stringData fill:#EC4899,stroke:#F472B6,color:#FFFFFF,font-weight:bold style arrow fill:#6366F1,stroke:#818CF8,color:#FFFFFF,font-weight:bold message["message (string)"] messagePtr["messagePtr (*string)"] stringData["String Data in Memory"] messagePtr -->|"points to"| message message -->|"initially contains"| stringData stringData -->|"Hello, Go!<br>↓<br>Updated via pointer!"| arrow
Example 3: Pointers to Structs
Struct pointers are extremely common in Go for modifying complex data types:
package main
import "fmt"
// Define a simple struct
type Cat struct {
Name string
Age int
Breed string
}
func main() {
// Create a cat struct
whiskers := Cat{
Name: "Whiskers",
Age: 3,
Breed: "Maine Coon",
}
// Create a pointer to the struct
whiskersPtr := &whiskers
// Print original struct
fmt.Println("Original:", whiskers)
// Access and modify using pointer
// Go allows direct field access with struct pointers
whiskersPtr.Age = 4
// This is equivalent to (*whiskersPtr).Age = 4
// See the changed struct
fmt.Println("Updated:", whiskers)
}
Go provides syntax sugar for working with struct pointers. Notice that we can use whiskersPtr.Age
instead of needing to write (*whiskersPtr).Age
. This convenience makes working with struct pointers much more readable.
Let’s visualize the struct pointer:
%%{init: {'theme':'dark'}}%% flowchart TD style whiskers fill:#F97316,stroke:#FB923C,color:#FFFFFF,font-weight:bold style whiskersPtr fill:#10B981,stroke:#34D399,color:#FFFFFF,font-weight:bold style struct fill:#EC4899,stroke:#F472B6,color:#FFFFFF,font-weight:bold style name fill:#6366F1,stroke:#818CF8,color:#FFFFFF,font-weight:bold style age fill:#6366F1,stroke:#818CF8,color:#FFFFFF,font-weight:bold style breed fill:#6366F1,stroke:#818CF8,color:#FFFFFF,font-weight:bold whiskers["whiskers (Cat)"] whiskersPtr["whiskersPtr (*Cat)"] struct["Cat Struct"] name["Name: 'Whiskers'"] age["Age: 3 → 4"] breed["Breed: 'Maine Coon'"] whiskers --- struct struct --- name & age & breed whiskersPtr -->|"points to"| struct
Function Parameters and Pointers
One of the most common uses of pointers in Go is to modify values within functions:
package main
import "fmt"
// This function modifies the cat via a pointer
func celebrateBirthday(c *Cat) {
c.Age++
fmt.Println("Happy Birthday, " + c.Name + "! You are now", c.Age)
}
type Cat struct {
Name string
Age int
Breed string
}
func main() {
mittens := Cat{
Name: "Mittens",
Age: 2,
Breed: "Tabby",
}
fmt.Println("Before:", mittens)
// Pass a pointer to the cat
celebrateBirthday(&mittens)
fmt.Println("After:", mittens)
}
Without pointers, Go is pass-by-value, meaning functions receive copies of arguments. By passing a pointer, you’re enabling the function to modify the original value. The celebrateBirthday
function can directly modify the Person
struct passed to it.
The function flow:
%%{init: {'theme':'dark'}}%% flowchart LR style main fill:#8B5CF6,stroke:#A78BFA,color:#FFFFFF,font-weight:bold style func fill:#EC4899,stroke:#F472B6,color:#FFFFFF,font-weight:bold style mittens fill:#F97316,stroke:#FB923C,color:#FFFFFF,font-weight:bold style cParam fill:#10B981,stroke:#34D399,color:#FFFFFF,font-weight:bold main["main() function"] func["celebrateBirthday() function"] mittens["mittens: Cat<br>Age: 2 → 3"] cParam["c *Cat<br>(points to mittens)"] main -->|"call with &mittens"| func main --- mittens func --- cParam cParam -->|"modifies"| mittens
Nil Pointers and Safety
Pointers in Go have a zero value of nil
, which requires careful handling:
package main
import "fmt"
func main() {
var ptr *int // Declared but not initialized
fmt.Println("Nil pointer:", ptr)
// Safety check before dereferencing
if ptr != nil {
fmt.Println("Value:", *ptr)
} else {
fmt.Println("Cannot dereference nil pointer!")
}
// Initialize the pointer
value := 42
ptr = &value
// Now safe to dereference
fmt.Println("Value after initialization:", *ptr)
}
Always check if a pointer is nil
before dereferencing it. Attempting to dereference a nil
pointer will cause a runtime panic, which crashes your program.
Visualizing nil pointer safety:
%%{init: {'theme':'dark'}}%% flowchart TD style ptr fill:#10B981,stroke:#34D399,color:#FFFFFF,font-weight:bold style nil fill:#F43F5E,stroke:#FB7185,color:#FFFFFF,font-weight:bold style check fill:#F59E0B,stroke:#FBBF24,color:#1F2937,font-weight:bold style value fill:#8B5CF6,stroke:#A78BFA,color:#FFFFFF,font-weight:bold style memory fill:#EC4899,stroke:#F472B6,color:#FFFFFF,font-weight:bold ptr["var ptr *int"] nil["nil (zero value)"] check["if ptr != nil"] value["value := 42"] memory["Memory with 42"] ptr -->|"initially"| nil nil --- check check -->|"fail"| safety["Safety Error<br>Prevented!"] ptr -.->|"later assigned"| memory value --- memory
Advanced Pointer Example: Custom Data Structure with Pointers
Let’s build a simple linked list, one of the classic data structures that relies on pointers:
package main
import "fmt"
// CatNode represents a node in a linked list of cats
type CatNode struct {
Name string
Age int
Next *CatNode // Pointer to the next cat node
}
// CatList represents a linked list of cats
type CatList struct {
Head *CatNode
}
// AddCat adds a new cat node to the end of the list
func (l *CatList) AddCat(name string, age int) {
newCat := &CatNode{Name: name, Age: age}
if l.Head == nil {
l.Head = newCat
return
}
current := l.Head
for current.Next != nil {
current = current.Next
}
current.Next = newCat
}
// PrintCats prints all cats in the list
func (l *CatList) PrintCats() {
current := l.Head
for current != nil {
fmt.Printf("%s (age %d) -> ", current.Name, current.Age)
current = current.Next
}
fmt.Println("nil")
}
func main() {
// Create a new cat list
catList := CatList{}
// Add cat nodes
catList.AddCat("Whiskers", 3)
catList.AddCat("Mittens", 2)
catList.AddCat("Felix", 5)
// Print the list
catList.PrintCats() // Output: Whiskers (age 3) -> Mittens (age 2) -> Felix (age 5) -> nil
}
This example demonstrates how pointers enable the creation of a linked list data structure:
- Each
CatNode
contains cat information and a pointer to the next node - The
CatList
has a pointer to the head cat node - The
AddCat
method traverses the list using pointers - The
PrintCats
method also traverses the list with pointers
Without pointers, implementing a linked list would be significantly more complex, if not im-paw-ssible.
When to Use Pointers in Go
Here’s a decision guide for when pointers make the most sense:
%%{init: {'theme':'dark'}}%% flowchart TD style start fill:#8B5CF6,stroke:#A78BFA,color:#FFFFFF,font-weight:bold style q1 fill:#F97316,stroke:#FB923C,color:#FFFFFF,font-weight:bold style q2 fill:#F97316,stroke:#FB923C,color:#FFFFFF,font-weight:bold style q3 fill:#F97316,stroke:#FB923C,color:#FFFFFF,font-weight:bold style usePointer fill:#10B981,stroke:#34D399,color:#FFFFFF,font-weight:bold style useValue fill:#F43F5E,stroke:#FB7185,color:#FFFFFF,font-weight:bold start["Do I need a pointer?"] q1["Need to modify the<br>variable in a function?"] q2["Working with a large<br>struct or array?"] q3["Need to express<br>optional/nil state?"] usePointer["Use a pointer (*T)"] useValue["Use a value (T)"] start --> q1 & q2 & q3 q1 -->|"Yes"| usePointer q2 -->|"Yes"| usePointer q3 -->|"Yes"| usePointer q1 -->|"No"| useValue q2 -->|"No"| useValue q3 -->|"No"| useValue
nil
.Common Pitfalls with Pointers
While Go’s pointers are safer than those in C/C++, there are still some common mistakes to avoid:
- Forgetting to check for nil: Always check if a pointer is
nil
before dereferencing it. (I’m definitely guilty of failing to do this on projects) - Returning pointers to stack variables: Don’t return pointers to local variables, as they become invalid when the function returns.
- Unnecessary pointer usage: Using pointers for small, simple values can actually be less efficient due to indirection and garbage collection overhead.
- Pointer arithmetic: Go intentionally does not support pointer arithmetic to prevent buffer overflows and other memory corruption issues.
Wrapping Up
Pointers in Go provide a powerful way to work with memory directly without the complexity and danger often associated with pointers in languages like C. They enable efficient memory usage and allow you to implement complex data structures like linked lists, trees, and graphs.
Key takeaways:
- Pointers store memory addresses, not values
- Use
&
to get an address,*
to get the value at an address - Pointers enable call-by-reference for modifying values in functions
- The zero value of a pointer is
nil
- Always check for nil before dereferencing
- Pointers are crucial for implementing data structures like linked lists
By now, you should have a solid understanding of how pointers work in Go and be ready to use them in your projects. Start simple, experiment, and gradually incorporate these concepts into your Go programming toolkit.