Profile it, ship it, and what comes next
Measure before you optimise, push it, and pick the right next thing to build.
Topic 87 of 98 · 5 min to read · 50 min to build
After this topic you can
- Capture a CPU or memory profile with pprof and find the hot spot.
- Write an honest benchmark with
b.Looporb.Nand compare results with benchstat. - Explain why you measure before optimising.
What profiling is
Profiling is measuring where a running program spends its time and memory. It matters because intuition about this is often wrong. The line you are sure is slow may not be, and the cost is somewhere you never looked.
Go ships a profiler in the standard library, called pprof. You expose it on a port, take a sample while the program is under load, and it shows which functions the time went into.
The rule that follows from this: correct first, clear second, fast only when a measurement says so. An optimisation without a profile is a guess, and it often costs readability and buys nothing.
pprof: never guess about performance
cmd/api/main.go
import _ "net/http/pprof" // registers handlers on the DEFAULT mux// a SEPARATE, localhost-only port — never your public onego func() { slog.Info("pprof on localhost:6060") // Log the error: losing the profiler should not kill the service, // but you want to know it failed (port in use, for example). if err := http.ListenAndServe("localhost:6060", nil); err != nil { slog.Error("pprof server stopped", "err", err) }}()terminal
# CPU: where is time going?go tool pprof -http=:8081 http://localhost:6060/debug/pprof/profile?seconds=30# memory in use right nowgo tool pprof -http=:8081 http://localhost:6060/debug/pprof/heap# total allocated over time — the allocation churngo tool pprof -http=:8081 http://localhost:6060/debug/pprof/allocs# goroutine leak hunting: does this number grow and never come back down?curl -s 'localhost:6060/debug/pprof/goroutine?debug=1' | head -1What usually costs you, roughly in order
| # | Cause | Typical fix |
|---|---|---|
| 1 | a query inside a loop (N+1) | a JOIN, or one batched query |
| 2 | a missing database index | EXPLAIN ANALYZE, then CREATE INDEX |
| 3 | a pool that is too small, or unlimited | SetMaxOpenConns |
| 4 | unbounded concurrency | a semaphore, or errgroup.SetLimit |
| 5 | allocations in a hot loop | preallocate with make([]T, 0, n) |
| 6 | everything else | rarely worth touching until a profile points at it |
Watch out: Do not do these on faith:
sync.Pool, unsafe string conversion, replacing a mutex with atomics, hand-rolled lock-free structures. Each needs a benchmark proving it *in your workload*. Without one, you pay in readability and may gain nothing.Benchmark honestly
bench_test.go
func BenchmarkValidate(b *testing.B) { req := CreateRequest{Title: "hello", Body: "world"} b.ReportAllocs() b.ResetTimer() // exclude the setup above for i := 0; i < b.N; i++ { _ = req.Validate() }}terminal
go test -bench=. -benchmem -count=10 ./... > old.txt# ... make the change ...go test -bench=. -benchmem -count=10 ./... > new.txtbenchstat old.txt new.txtgo install golang.org/x/perf/cmd/benchstat@latestShip it
terminal
make check # fmt, vet, race, govulncheck — all cleangit init && git add -Agit commit -m "SnapNotes API"git remote add origin git@github.com:you/snapnotes.gitgit push -u origin mainThe habit that makes you senior
before every push
gofmt -l . # silentgo vet ./... # silentgo test -race ./... # greengovulncheck ./... # cleanWhat to build next
- A CLI with
flagand subcommands: a different shape of program from a server. - A concurrent web crawler with bounded workers, dedup, and a context timeout. It makes you use every concurrency tool from this track at once.
- A rate limiter as a library, with benchmarks and fuzz tests.
- A queue worker (Redis, NATS, or SQS) with graceful shutdown and retries.
- Add caching, metrics, and tracing to SnapNotes.
What to read next
- Effective Go: reread it in a month. It means much more once you have written real code.
- Go Code Review Comments: the Go wiki's list of what reviewers commonly flag.
- The Go Memory Model: short, and it settles arguments about when one goroutine is guaranteed to see another's writes.
- The Go Blog: start with pipelines, context, and errors-are-values.
- The standard library source.
net/http,io,errors, andsyncare some of the clearest Go you can read, and it is already on your disk.
Worth knowing: You started this track not knowing what a goroutine was. You now have a Go API with authentication, a database, file uploads, tests, and a container that runs it. That is a real portfolio project and a real skill. Good Go code is boring. If a reviewer says "this is clever", that is not the compliment it sounds like.
The short version
- Profile before optimising. pprof on localhost only, never a public port.
- The database is where your latency lives. Micro-optimising Go is the last resort.
- Benchmark with
-count=10and benchstat, or you are reading noise. - fmt, vet, race, govulncheck before every push. Forever.
Remember: Correct, then clear, then fast, and only fast when a measurement told you to. An optimisation written without a profile often makes the code harder to read and no faster.
Build this before moving on
Profile the API under a hey or ab load test, find the slowest endpoint, and fix one real thing. Then run make check, push it, and take the rest of the day off.Then try these in your own editor
- 1Load-test with
hey -n 2000 -c 50and capture a 30-second CPU profile while it runs. - 2Find your slowest endpoint from request latency (not the CPU profile) and fix one real thing.
- 3Benchmark that fix with
-count=10andbenchstat. If the difference is not significant, revert it. - 4Watch the goroutine count during the load test and confirm it returns to baseline afterwards.
Official documentation