08 - Pointers and Memory¶
What this session is¶
About an hour. Pointers are the topic that scares beginners the most and turns out to be small once you actually meet them. By the end of this page you'll understand what a pointer is, when to use one, and why methods sometimes have *Type in their receiver.
We will go slowly. Pointers feel weird the first three times.
The problem¶
When you pass a value to a function, Go gives the function its own copy. Changes the function makes don't affect the caller.
package main
import "fmt"
type Counter struct {
Value int
}
func increment(c Counter) {
c.Value = c.Value + 1
}
func main() {
counter := Counter{Value: 0}
increment(counter)
fmt.Println(counter.Value) // 0 - not 1!
}
Type and run. Surprise: the counter is still 0. Why?
increment received its own copy of the counter. It bumped that copy. The original is untouched.
For numbers this is fine - most functions don't want to mutate their inputs. But sometimes you do. You want increment(counter) to actually increment the counter. That's what pointers are for.
What a pointer is¶
A pointer is a value that stores the address of another value. Instead of being "the counter," a pointer is "where the counter lives."
Two operators:
&x- read as "the address of x." Gives you a pointer tox.*p- read as "the value pointed to by p." Goes from pointer to actual value. Called dereferencing.
package main
import "fmt"
func main() {
x := 42
p := &x // p is a pointer to x
fmt.Println(x) // 42
fmt.Println(p) // something like 0xc00001a098 - an address
fmt.Println(*p) // 42 - what p points to
*p = 100 // write through the pointer
fmt.Println(x) // 100 - x changed!
}
Type and run carefully. Notice the two important moves:
p := &xmadeppoint atx. Nowpknows wherexlives.*p = 100followed the pointer back toxand wrote100into it.xis now100.
The type of p is *int. Read it as "pointer to int." You'll see this notation in function signatures: func foo(n *int) means "foo takes a pointer to int."
Fixing the counter¶
package main
import "fmt"
type Counter struct {
Value int
}
func increment(c *Counter) {
c.Value = c.Value + 1
}
func main() {
counter := Counter{Value: 0}
increment(&counter)
fmt.Println(counter.Value) // 1!
}
Two changes:
func increment(c *Counter)- the parameter is now a pointer to aCounter, not a Counter.increment(&counter)- we pass the address ofcounter, not a copy.
Inside increment, c.Value = ... writes through the pointer to the original. The change sticks.
Notice: even though c is a pointer, we wrote c.Value, not (*c).Value. Go quietly does the dereferencing for field access. This is purely a convenience. Both forms work; the short one is what everyone writes.
Pointer receivers on methods (revisited from page 05)¶
Now the * on a method receiver makes sense:
This says: "the receiver is a pointer to a Counter, not a copy of one." When you call counter.Increment(), Go quietly passes the address of counter, the method mutates through it, the change sticks.
Compare to a value receiver:
This compiles, runs, and does nothing visible. A classic beginner bug.
The rule of thumb (stronger version of what page 05 said):
- If the method modifies the receiver's fields → use
*Type. - If the receiver is a big struct (lots of fields) → use
*Typeeven if you don't modify it. Saves the copy. - If the receiver is small and immutable → either works; pick one and be consistent across all methods on the type.
When in doubt, use *Type. Real Go code uses pointer receivers ~80% of the time.
nil pointers¶
A pointer can be nil - pointing at nothing. Dereferencing a nil pointer is a panic (crash).
var p *int // p is nil
fmt.Println(p == nil) // true
fmt.Println(*p) // PANIC: nil pointer dereference
You'll see this crash often when starting out. The fix is almost always "make sure the pointer is set to something before you use it." Either initialize it (p := &x) or check before dereferencing:
When to use pointers¶
The honest answer:
- A method needs to mutate its receiver. Use a pointer receiver.
- You're passing a large struct around and don't want to copy it every time. Use a pointer.
- You explicitly want a "nullable" value - a thing that might or might not be set. Use a pointer; check
nil. - You're sharing state between functions, and they all need to see updates. Use a pointer.
Otherwise: pass values around. Copying is cheap for small things. Go's compiler is good at avoiding actual copies when it safely can.
What about memory? Stack vs heap?¶
You may have heard programmers talk about "stack" and "heap." Quick story:
- The stack is fast memory used for function calls - every call grows it; every return shrinks it. Limited size.
- The heap is general-purpose memory that outlives any single function call. Garbage-collected (Go cleans up things you stop using).
Go decides for you. The compiler does escape analysis to figure out whether a value can safely live on the stack (faster) or must live on the heap (longer-lived). You almost never have to think about it.
The only thing you need to know now: taking the address of something with & may or may not cause it to live on the heap. Don't worry about it. Write clear code; let the compiler decide.
The full picture lands when you read "Go runtime" content later. For now: trust the language.
Going deeper¶
You can write production Go without knowing any of this. But a few things will eventually matter for performance, debugging, or just reading senior code with confidence.
Watch the compiler decide: -gcflags="-m"¶
You can ask the compiler to tell you what escaped to the heap and why:
$ go build -gcflags="-m" main.go
./main.go:5:6: can inline counter
./main.go:11:8: &n escapes to heap: flow: ~r0 = &n
./main.go:11:8: moved to heap: n
"Moved to heap" means: this variable, which looks local, actually lives on the heap because something escaped. Most of the time that's fine. In hot paths it's worth reducing.
The two common reasons something escapes:
1. You return a pointer to it. The caller might keep that pointer past your function's lifetime, so the value can't die when your function does.
2. You store it in something whose lifetime the compiler can't prove (e.g. an interface{} value passed to fmt.Println - the compiler often can't tell what that function will do with it).
You won't tune this for normal code. You will eventually use it to chase a benchmark.
Struct field order matters (a little)¶
Go aligns struct fields on natural boundaries. A bool is 1 byte but the next field might need to start on an 8-byte boundary, leaving 7 bytes of padding:
type Sparse struct {
A bool // 1 byte
// 7 bytes padding
B int64 // 8 bytes
C bool // 1 byte
// 7 bytes padding
} // 24 bytes total
type Dense struct {
B int64 // 8 bytes
A bool // 1 byte
C bool // 1 byte
// 6 bytes padding at the end
} // 16 bytes total
Dense packs the same fields into 16 bytes by grouping the wide fields first. For one struct, 8 bytes is nothing. For a slice of a million of them, it's 8 MB.
The order isn't a style rule - readability comes first - but if a struct is allocated in huge quantities and you're hunting bytes, group wide fields together.
sync.Pool for hot allocations¶
Suppose your HTTP handler builds a 4KB buffer for every request. Each allocation pressures the garbage collector. sync.Pool lets you reuse them:
var bufPool = sync.Pool{
New: func() any { return make([]byte, 0, 4096) },
}
func handle(w http.ResponseWriter, r *http.Request) {
buf := bufPool.Get().([]byte)[:0] // grab one, reset length
defer bufPool.Put(buf)
// ... use buf ...
}
A pool is a per-goroutine cache. Two surprises:
- The pool can drop anything any time (especially during GC). It's a cache, not storage.
- Get returns a value that might still have stuff in it from before. Reset before using.
Production servers use this heavily for buffers and request structs. Don't reach for it preemptively - only when allocation profiles show it's worth it.
Stacks grow (Go's secret weapon)¶
Each goroutine starts with a tiny stack - 8 KB on modern Go. If your function calls go deeper than that, the runtime detects it, allocates a bigger stack, copies the current stack into the new one, fixes up pointers, and continues. You see nothing.
This is why Go can run a million goroutines with reasonable memory: most of them never need more than the initial 8 KB. It's also why deep recursion in Go doesn't blow up like it does in some other languages - until you actually hit the per-goroutine cap (1 GB by default).
You'll never write code for this. But when someone says "goroutines are cheap because of growable stacks," now you know what they mean.
Reading a memory profile¶
When something feels slow, the next-level move is pprof:
import _ "net/http/pprof"
func main() {
go http.ListenAndServe("localhost:6060", nil)
// ... rest of your program ...
}
Now while your program runs:
You'll see which functions allocated the most memory. It's the Go-native answer to "where is my program spending bytes."
You'll learn this in depth later. For now: know it exists, know how to start it, recognize the output when you see it.
Exercise¶
In a new file account.go:
-
Define a struct
Accountwith two fields:Owner(string) andBalance(float64). -
Add a method
Deposit(amount float64)that addsamounttoBalance. Choose the right receiver type (value or pointer). -
Add a method
Withdraw(amount float64) errorthat: - Subtracts
amountfromBalanceifBalance >= amount. - Returns
nilon success. -
Returns an error like "insufficient funds: have X, want Y" otherwise.
-
In
main: -
Make sure both Deposit and Withdraw actually mutate
acct. (If they don't, you picked the wrong receiver type.)
What you might wonder¶
"Why don't I always use pointers, to be safe?"
Two reasons. (1) Pointers can be nil, which means an extra failure mode. (2) Pointers point at shared state, which means accidental mutation surprises. Values are simpler when simpler will do.
"Pointers in C scared me. Are Go pointers like that?"
A lot less scary. No pointer arithmetic (you can't p + 1 to "move along an array"). No manual free() - the garbage collector handles it. No casting between types. About 80% of what makes C pointers scary doesn't exist in Go.
"Is *Counter a different type from Counter?"
Yes, but Go is forgiving. You can call value-receiver methods on a pointer and vice versa most of the time. The type is technically distinct; the syntax is convenient.
"What's a **Counter?"
A pointer to a pointer. You'll see it rarely. Real-world reasons: a function that wants to replace what a pointer points to. Don't worry about it now; recognize it when you see it.
Done¶
You can now:
- Explain what a pointer is: a value holding the address of another value.
- Use &x to take an address and *p to dereference.
- Choose between value and pointer receivers on methods (mutation → pointer).
- Recognize and avoid nil pointer crashes.
- Stop fearing pointers.
You've now learned every basic mechanic of Go the language. From here on, the pages are about putting these mechanics together: concurrency (using all of them at once across multiple things running in parallel), testing (verifying your code does what you think), packages (organizing it), and eventually reading and contributing to real projects.
Next page: how Go does "do many things at once" - goroutines and channels.
Next: Concurrency 101 → 09-concurrency-101.md