Performance & Optimization
Here's the thing nobody tells you when you start chasing speed: most of your code is already fast enough, and most of your guesses about where it's slow will be wrong. Performance work is a discipline: measure, find the one place that matters, fix that, and stop. The tricks are the easy part; the discipline separates a real speedup from hours of busywork that moved nothing.
This phase caps the deep half of the guide, leaning on the runtime model from Phase 14 (stack, heap, escape analysis, GC) and the tools from Phase 15 (go test -bench, pprof), in the order that pays off: measure, fix the algorithm, then cut allocations.
Measure first, always
The mental model. Your intuition about performance is a liar - not because you're bad at this, but because modern CPUs, caches, the scheduler, and the GC interact in ways no human predicts reliably. The function you're sure is the bottleneck is often a rounding error, while the real cost hides in a string concatenation you never thought twice about. The only way to know is to look.
⚠️ The number-one rule of optimization: never optimize on a hunch. Every time you "speed something up" without measurements proving it was slow and that your change helped, you're gambling - and the usual prize is uglier code at the same speed. Profile first. Always.
The workflow is the one from Phase 15, used in anger:
- Write a benchmark that exercises the real, representative work.
- Run it under the CPU profiler to find where time actually goes.
- Fix the single biggest cost.
- Re-run the benchmark to prove the fix helped. Repeat from step 2.
$ go test -bench=. -cpuprofile=cpu.prof
$ go tool pprof -top cpu.prof
Showing nodes accounting for 1.84s, 92.0% of 2.00s total
flat flat% sum% cum cum%
1.20s 60.0% 60.0% 1.20s 60.0% main.findDuplicates
0.40s 20.0% 80.0% 0.40s 20.0% runtime.mapassign_faststr
0.24s 12.0% 92.0% 0.24s 12.0% runtime.mallocgc
What just happened: pprof -top ranked functions by CPU time burned. Sixty percent sits in one function, findDuplicates - your hot spot, nothing else worth touching until it's handled. The mallocgc line (allocation) is a hint we'll come back to. (Numbers vary by machine; the shape - one function dominating - is typical.)
💡 Key insight. In almost every program, a tiny fraction of the code accounts for the overwhelming majority of runtime. Your job is to find that 3% and leave the other 97% alone, readable and untouched. Profiling finds the 3%; optimizing the rest only adds risk.
Algorithmic cost dominates
The mental model. Before fiddling with a single allocation, ask: is the approach itself right? The largest wins in practice almost never come from micro-tweaks - they come from replacing an expensive strategy with a cheaper one, turning an O(n²) nested scan into an O(n) pass with a map. No low-level cleverness rescues a quadratic algorithm; it just delays the cliff.
If "O(n²)" and "O(n)" feel fuzzy, the dedicated primer Big-O Without the Math Panic walks through exactly what they mean and why they decide who wins as your data grows.
Here's the classic: finding items in a slice that appear more than once. The naive version compares every element against every other:
// O(n²): for each item, scan all the others looking for a match.
func findDuplicatesSlow(items []string) []string
The map version makes one pass, remembering what it has seen:
// O(n): one pass, a map remembers what we've already seen.
func findDuplicatesFast(items []string) []string
What just happened: both answer the same question, but the cost curves aren't alike. The slow version's inner loop grows with the square of the input - double the items, quadruple the comparisons. The fast version trades a little memory (the seen map) for a single linear pass: a map lookup is roughly constant-time, so doubling the input only doubles the work. On 10 items the difference is invisible; on 100,000 it's instant versus a coffee break.
Benchmarked side by side, the gap is brutal:
$ go test -bench=Duplicates -benchmem
BenchmarkDuplicatesSlow-8 37 31_847_201 ns/op analysis on 10k items
BenchmarkDuplicatesFast-8 5_142 233_004 ns/op analysis on 10k items
What just happened: at 10,000 items the map-based version is over a hundred times faster (ns/op = nanoseconds per operation, lower is better), and the multiplier grows with input size: at 100,000 the quadratic version is thousands of times slower. Algorithm choice dwarfs everything else - you cannot micro-optimize your way out of the wrong complexity class.
Play with how each growth curve behaves as n climbs - it makes the O(n²)-vs-O(n) gap concrete in a way numbers on a page can't:
Allocations are the usual Go bottleneck
The mental model. Once your algorithm is sound, the most common remaining drag in Go is heap allocation. Recall Phase 14: values escaping to the heap cost more than stack values, and every heap allocation is something the GC must later track and reclaim. More allocations means more GC work stealing CPU. So "make it faster" often means "make it allocate less."
📝 allocs/op - the average number of distinct heap allocations one run of a benchmarked operation makes, seen via -benchmem. Often a better optimization target than raw time, since cutting allocations cuts GC pressure, lowering time and steadying performance under load.
The single most common waste: growing a slice from nothing when you already know how big it'll get. Each time append runs out of capacity it allocates a new, larger backing array and copies everything over, so a slice built one element at a time can allocate many times over its life.
// Wasteful: starts empty, reallocates the backing array as it grows.
func squaresGrowing(n int) []int
// Lean: one allocation, exactly the right size, up front.
func squaresPrealloc(n int) []int
What just happened: squaresGrowing starts with a nil slice and lets append discover the size the hard way, grabbing a bigger array and copying old contents in each time capacity runs out. squaresPrealloc calls make([]int, 0, n): capacity n reserved immediately, so every append drops into space that already exists - the whole slice costs exactly one allocation. Same output, a fraction of the garbage.
$ go test -bench=Squares -benchmem
BenchmarkSquaresGrowing-8 291_204 4_071 ns/op 16_376 B/op 12 allocs/op
BenchmarkSquaresPrealloc-8 876_553 1_355 ns/op 8_192 B/op 1 allocs/op
What just happened: -benchmem added two columns: B/op (bytes allocated per operation) and allocs/op (count of allocations). The growing version made 12 separate allocations and churned twice the memory; the preallocated version made exactly 1 - roughly a 3x speedup from one trivially small change.
Two other everyday spots for the same principle:
- Reuse buffers instead of re-creating them. Building strings with
+in a loop allocates a fresh string every concatenation; astrings.Builder(or reused[]byte) writes into one growing buffer. - Avoid needless boxing into
interface{}or pointers. Stuffing a value into an empty interface, or passing its address around, is exactly what makes it escape to the heap (Phase 14). In a hot loop, passing values directly keeps them on the stack - free to create, nothing for the GC to chase. Let the profiler andgo build -gcflags=-mtell you what's escaping.
sync.Pool - recycle short-lived temporaries
The mental model. Sometimes a hot path must allocate a chunky temporary over and over - a scratch buffer, a parser's work area - and each allocation is GC pressure. sync.Pool is Go's tool: a free list of already-allocated objects you borrow and return, so a few objects get reused across thousands of operations instead of allocating fresh each time.
📝 sync.Pool - a concurrency-safe pool of reusable, temporary objects. Get() returns one (creating it via New only if empty); Put() hands it back for the next caller. The point: recycle fungible temporaries and slash allocation churn in hot paths.
package main
import (
"bytes"
"fmt"
"sync"
)
// Pool of reusable byte buffers. New runs only when the pool is empty.
var bufPool = sync.Pool
func render(msg string) string
func main()
$ go run main.go
[log] started
[log] done
What just happened: the first render call found the pool empty, so New made a fresh bytes.Buffer. On the way out, Reset() cleared it and Put() returned it; the second call's Get() handed back that same buffer instead of allocating a new one. Across a hot path doing this millions of times, you allocate a handful of buffers total - a large cut in GC work. Note Reset(): a pooled object carries whatever the last borrower left in it, so clear it before reuse.
⚠️ sync.Pool is not a cache, and doesn't keep your objects alive. The GC can empty the pool at any collection - an object you Put may be gone the next Get (New just makes a new one, no harm done). Fine for fungible temporaries, completely wrong for anything you need to persist, with identity, or expensive to lose. Use it only to relieve measured allocation pressure on interchangeable short-lived objects.
Knowing when to stop
The mental model. Optimization has a point of diminishing - then negative - returns. Every clever rewrite makes code harder to read, change, and break, a cost paid by every future reader. The goal is never "as fast as physically possible" - it's "fast enough, and no more twisted than it has to be."
The discipline:
- Optimize the measured hot path. Leave the rest clear. The 3% that profiling flagged earns the right to be clever; the other 97% stays as straightforward as possible.
- Define "fast enough" up front, then stop when you hit it. A target ("p99 under 50ms") tells you when you're done. Without one, optimization never ends.
- Re-measure after every change. A change that doesn't move the benchmark isn't an optimization - revert it.
💡 The closing rule of the deep half. Readable code that's fast enough beats clever code that's unmaintainable, every time. Measure, fix the algorithm, cut the allocations that matter, and then - the hardest part - stop.
Recap
- Measure first, always. Never optimize on a hunch. Use benchmarks plus
pprofto find the real hot spot; most code is already fast enough, so hunt the 3% that isn't. - Algorithmic cost dominates. The biggest wins come from a better approach (O(n) map lookup over an O(n²) nested scan), not micro-tweaks - you can't optimize your way out of the wrong complexity class.
- Allocations are the usual Go bottleneck. Fewer heap allocations means less GC pressure means faster, steadier code. Preallocate with
make([]T, 0, n), reuse buffers, and avoid needless boxing that escapes to the heap; watchallocs/opwith-benchmem. sync.Poolrecycles fungible temporaries in hot paths to cut allocation churn - but it's not a cache; the GC can empty it anytime, so use it only for interchangeable short-lived objects, andResetbefore reuse.- Know when to stop. Optimize the measured hot path and leave the rest clear; define "fast enough" up front, re-measure after every change, and revert anything that didn't move the number.
That's the deep half done. You can now reason about how Go runs your code and make it faster on purpose, with evidence instead of guesses. The final phase steps back: where Go genuinely shines, and where to point yourself next.
Quick check
Test yourself on the discipline that makes performance work actually pay off:
[
{
"q": "Before changing any code to make a Go program faster, what should you do first?",
"choices": [
"Profile with benchmarks and pprof to find where time actually goes",
"Add sync.Pool everywhere objects are created",
"Rewrite the slowest-looking function from memory",
"Switch every slice to a preallocated fixed size"
],
"answer": 0,
"explain": "Intuition about bottlenecks is unreliable. Measure first with benchmarks and pprof so you optimize the real hot spot - the small fraction of code that actually dominates runtime - instead of guessing."
},
{
"q": "You replace a slow function and want to know if it mattered. Which single change usually delivers the biggest speedup on large inputs?",
"choices": [
"Choosing a better algorithm - e.g. an O(n) map lookup instead of an O(n²) nested scan",
"Renaming variables so the compiler optimizes better",
"Adding more goroutines to the inner loop",
"Removing all comments from the hot path"
],
"answer": 0,
"explain": "Algorithmic complexity dominates. Turning a quadratic approach into a linear one wins by a margin that grows with the input - no micro-optimization can rescue the wrong complexity class."
},
{
"q": "Why is sync.Pool wrong for storing objects you need to keep around?",
"choices": [
"The garbage collector can empty the pool at any GC, so a pooled object may vanish",
"sync.Pool is not safe for concurrent use",
"Objects in a pool are deep-copied, doubling memory use",
"Get() always allocates a brand-new object, defeating the purpose"
],
"answer": 0,
"explain": "sync.Pool is a free list for fungible temporaries, not a cache. The GC can clear it during any collection, so anything you Put may be gone on the next Get. It's only safe for interchangeable short-lived objects where losing one is harmless."
}
]
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 3 questions
1. Before changing any code to make a Go program faster, what should you do first?
2. You replace a slow function and want to know if it mattered. Which single change usually delivers the biggest speedup on large inputs?
3. Why is sync.Pool wrong for storing objects you need to keep around?