Benchmarking & Profiling
Prerequisites: Testing strategies: httptest, fakes & fuzzing
Performance work starts with measurement. Go ships a benchmark runner in
go test and a profiler, pprof, so you rarely need anything else.
Writing a benchmark
Benchmarks live in _test.go files, start with Benchmark, and take a
*testing.B. The body runs b.N times; the framework picks b.N so the
benchmark runs long enough to measure:
// concat_test.go
package concat
import (
"strings"
"testing"
)
var parts = strings.Fields("the quick brown fox jumps over the lazy dog")
func concatPlus(p []string) string {
s := ""
for _, x := range p {
s += x
}
return s
}
func concatBuilder(p []string) string {
var b strings.Builder
for _, x := range p {
b.WriteString(x)
}
return b.String()
}
var sink string
func BenchmarkPlus(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
sink = concatPlus(parts)
}
}
func BenchmarkBuilder(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
sink = concatBuilder(parts)
}
}
go test -bench=. -benchmem
One run on a 12-thread laptop printed (your timings will differ):
BenchmarkPlus-12 4092812 292.9 ns/op 208 B/op 8 allocs/op
BenchmarkBuilder-12 8290458 148.1 ns/op 120 B/op 4 allocs/op
Columns: iterations run, time per operation, bytes allocated per operation,
and allocations per operation. The -12 suffix is GOMAXPROCS. += makes
a new string for every word after the first; the builder only reallocates
when its buffer fills up.
b.ReportAllocs() (or the -benchmem flag) adds the memory columns.
Go 1.24 adds b.Loop() as a simpler replacement for the b.N loop; on
Go 1.23 use the loop shown here.
Benchmark hygiene
- Assign results to a package-level variable (
sink) so the compiler can't eliminate the work. - Do setup before the loop and call
b.ResetTimer()after it. - Use
b.Runfor sub-benchmarks across input sizes. - Run several times (
-count=10) and compare withbenchstat(go install golang.org/x/perf/cmd/benchstat@latest) — a single run is noisy, and a 3% difference can be pure chance. - Benchmark on a quiet machine; laptops on battery throttle.
func BenchmarkBuilderSizes(b *testing.B) {
for _, n := range []int{10, 100, 1000} {
input := make([]string, n)
for i := range input {
input[i] = "x"
}
b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
for i := 0; i < b.N; i++ {
sink = concatBuilder(input)
}
})
}
}
CPU and memory profiles
A benchmark tells you how slow; a profile tells you where. go test can
write profiles directly:
go test -bench=BenchmarkPlus -cpuprofile=cpu.out -memprofile=mem.out
go tool pprof cpu.out
(pprof) top 10
(pprof) list concatPlus
top lists the functions using the most CPU; list shows per-line cost in a
function. go tool pprof -http=:8081 cpu.out opens a browser UI with a flame
graph, usually the fastest way to read a profile. For memory profiles,
-sample_index=alloc_space shows total bytes allocated rather than bytes
still in use.
Profiling a running server
Importing net/http/pprof registers handlers under /debug/pprof/ on
http.DefaultServeMux. Serve that on a separate, internal-only port:
package main
import (
"log"
"net/http"
_ "net/http/pprof"
)
func main() {
go func() {
// Localhost only — never expose pprof publicly.
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
appMux := http.NewServeMux()
appMux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello"))
})
log.Fatal(http.ListenAndServe(":8080", appMux))
}
Then capture 30 seconds of CPU profile while the server is under load, or a heap snapshot:
go tool pprof "http://localhost:6060/debug/pprof/profile?seconds=30"
go tool pprof http://localhost:6060/debug/pprof/heap
/debug/pprof/goroutine?debug=2 dumps every goroutine's stack — invaluable
for finding goroutine leaks. For command-line programs, use
runtime/pprof.StartCPUProfile / StopCPUProfile instead.
Common mistakes
- Optimising without a profile. The slow part is rarely where you guess.
- Comparing single benchmark runs instead of using
-countandbenchstat. - Serving
net/http/pprofon the public port — it leaks internals and lets anyone trigger expensive profiling. - Benchmarking with setup inside the loop, measuring the setup instead of the code.
Practice
Write benchmarks comparing strconv.Itoa(n) with fmt.Sprintf("%d", n) for
n = 12345, reporting allocations. Which would you expect to be faster, and
why?
Show solution
package conv
import (
"fmt"
"strconv"
"testing"
)
var (
sink string
n = 12345
)
func BenchmarkItoa(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
sink = strconv.Itoa(n)
}
}
func BenchmarkSprintf(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
sink = fmt.Sprintf("%d", n)
}
}
strconv.Itoa is typically a few times faster and allocates less: it formats
one integer directly, while fmt.Sprintf parses the format string and boxes
its argument into an interface. Reading n from a variable keeps the compiler
from treating the input as a constant. Run go test -bench=. -count=5 to
confirm on your machine.