Go is fast by default — the runtime is efficient, goroutines are lightweight, and the compiler applies meaningful optimizations. Most performance problems in production Go code come from a small number of patterns: too many allocations, lock contention, or algorithmic issues that profiling reveals quickly.
The workflow is always: measure, identify the bottleneck, fix it, measure again. Optimizing without profiling is guesswork. This guide covers the tools and techniques for the measurement-to-fix cycle.
For concurrency-specific tuning see Go concurrency performance tuning. For profiling basics see Go profiling CPU and memory.
The Profiling Workflow
Enable pprof in any service with a single import:
import _ "net/http/pprof" // registers /debug/pprof/ handlers
go http.ListenAndServe(":6060", nil)
Collect and analyze profiles while the service runs under real load:
# CPU profile — 30 second sample of where the program spends time
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
# Heap profile — what's allocated right now
go tool pprof http://localhost:6060/debug/pprof/heap
# Open interactive flame graph in browser
go tool pprof -http=:8080 cpu.prof
In the interactive shell, top shows the hottest functions. list funcname shows line-by-line time for a specific function. The flame graph view is usually the most intuitive for finding where time goes.
For production services, use a sampling rate (SetMutexProfileFraction(10), SetBlockProfileRate(1)) rather than sampling everything — overhead is proportional to the rate.
Escape Analysis: Controlling Allocations
Every allocation puts pressure on the GC. The escape analysis compiler flag shows which values are allocated on the heap vs the stack:
go build -gcflags="-m" ./...
# main.go:15:12: moved to heap: user
# main.go:22:6: &config escapes to heap
Values that “escape to heap” cause GC work. Common escape patterns:
// ❌ Allocates: pointer to local returned — value must outlive the function
func newUser() *User {
u := User{Name: "Alice"}
return &u // u escapes to heap
}
// ✅ No allocation: value returned directly — stays on stack
func newUser() User {
return User{Name: "Alice"}
}
// ❌ Allocates: interface boxing — concrete value escapes when stored in interface
var cache map[string]any
cache["key"] = User{Name: "Alice"} // User escapes when stored as any
// ✅ Typed map avoids interface boxing
var cache map[string]User
cache["key"] = User{Name: "Alice"}
A key insight: taking the address of a value (&u) is only an allocation if the pointer escapes the current function scope. The compiler is smart about this — use -gcflags="-m" to see what it decides.
Reducing Allocations in Hot Paths
benchstat and -benchmem show allocations per operation:
go test -bench=. -benchmem -count=5 ./...
# BenchmarkProcessRequest-8 100000 15234 ns/op 2048 B/op 12 allocs/op
# ↑ ↑
# bytes/call allocs/call
Techniques to reduce allocations:
Pre-allocate slices when size is known:
// ❌ Multiple allocations as slice grows
var results []Result
for _, item := range items {
results = append(results, process(item))
}
// ✅ One allocation
results := make([]Result, 0, len(items))
for _, item := range items {
results = append(results, process(item))
}
Reuse buffers with sync.Pool:
var bufPool = sync.Pool{
New: func() any { return bytes.NewBuffer(make([]byte, 0, 4096)) },
}
func formatResponse(data any) ([]byte, error) {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
defer bufPool.Put(buf)
if err := json.NewEncoder(buf).Encode(data); err != nil {
return nil, err
}
return bytes.Clone(buf.Bytes()), nil
}
Use value types instead of pointers for small structs:
// ❌ Heap allocation per call
func getConfig() *Config { return &Config{Timeout: 30} }
// ✅ Stack allocation — Config is small (24 bytes or so)
func getConfig() Config { return Config{Timeout: 30} }
Cache-Friendly Data Layout
Modern CPUs are 10–100x faster accessing L1 cache than RAM. Sequential memory access patterns allow the hardware prefetcher to load data ahead of time:
// ❌ Random access — cache unfriendly
type UserDB struct {
users map[int]*User // pointer chasing on every access
}
// ✅ Sequential — cache friendly
type UserDB struct {
users []User // contiguous memory, sequential access
index map[int]int // int→index into users slice
}
// ❌ Column-major access of row-major data
for col := 0; col < cols; col++ {
for row := 0; row < rows; row++ {
sum += matrix[row][col] // jumps row*cols bytes each iteration
}
}
// ✅ Row-major access matches memory layout
for row := 0; row < rows; row++ {
for col := 0; col < cols; col++ {
sum += matrix[row][col] // sequential access
}
}
Struct field ordering also matters. Group fields by size (largest first) to minimize padding:
// ❌ 32 bytes — 14 bytes of padding
type Bad struct {
A bool // 1 byte
B int64 // 8 bytes — 7 bytes of padding before B
C bool // 1 byte
D int64 // 8 bytes — 7 bytes of padding before D
}
// ✅ 18 bytes — 0 bytes of padding
type Good struct {
B int64 // 8 bytes
D int64 // 8 bytes
A bool // 1 byte
C bool // 1 byte
}
Use fieldalignment from golang.org/x/tools/go/analysis/passes/fieldalignment to detect suboptimal struct layouts.
GOMAXPROCS and CPU Binding
GOMAXPROCS controls how many OS threads run goroutines simultaneously. The default is runtime.NumCPU(), which is correct for CPU-bound work. For I/O-bound services (most web services), the default is also fine.
Situations where you might change it:
// Reduce GOMAXPROCS in a container with CPU limits
// (Go 1.21+ reads cgroup CPU limits automatically — no manual tuning needed)
runtime.GOMAXPROCS(1) // single-threaded, no goroutine scheduling overhead
// Check what pprof goroutine scheduler thinks
// go tool pprof http://localhost:6060/debug/pprof/goroutine
For services with mixed CPU and I/O work, the default NumCPU() is almost always right. Changing GOMAXPROCS is one of the last optimizations to try, not the first.
Benchmarking Correctly
The Go test framework has a built-in benchmarker. Common mistakes that produce misleading results:
func BenchmarkHash(b *testing.B) {
// ✅ Expensive setup outside the loop
data := make([]byte, 1024)
rand.Read(data)
b.ResetTimer() // don't count setup time
for i := 0; i < b.N; i++ {
// ✅ Prevent dead code elimination — use the result
result := sha256.Sum256(data)
_ = result // compiler can't eliminate this
}
}
// ✅ Parallel benchmark for concurrency testing
func BenchmarkConcurrent(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
hotFunction()
}
})
}
Use benchstat to compare before/after:
go test -bench=BenchmarkHash -benchmem -count=10 | tee before.txt
# make change
go test -bench=BenchmarkHash -benchmem -count=10 | tee after.txt
benchstat before.txt after.txt
benchstat computes statistical significance — it tells you whether the change is real or noise. A 5% difference in a single benchmark run is often just noise; benchstat will tell you the p-value.
Finding Leaks: Goroutines and Memory
Goroutine count trending upward is a goroutine leak. Memory growing without bound despite stable traffic is a heap leak. Both are visible in pprof:
# Goroutine count
curl http://localhost:6060/debug/pprof/goroutine?debug=1
# Heap growth — take two snapshots, compare
go tool pprof http://localhost:6060/debug/pprof/heap
# In pprof: top20 inuse_objects
In tests, use goleak from Uber to fail if any goroutines leak:
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
Summary
- Profile first — CPU pprof, heap pprof, mutex pprof; the flame graph view is usually the fastest way to find the bottleneck
-gcflags="-m"shows escape analysis decisions — reduce heap allocations by returning values instead of pointers where the pointer doesn’t need to outlive the functionsync.Poolfor short-lived reusable buffers; pre-allocate slices with known capacity- Sequential memory access patterns outperform random access by 10–100x; row-major loops for row-major data
go test -benchmem -count=10+benchstatfor statistically significant before/after comparisons- Monitor goroutine count in production; use
goleakin tests to catch leaks during development
Resources
- net/http/pprof documentation
- Go Blog: Profiling Go Programs
- benchstat
- goleak goroutine leak detector
Comments