Skip to main content

Performance Tuning Go Systems

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 6 min read

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 function
  • sync.Pool for 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 + benchstat for statistically significant before/after comparisons
  • Monitor goroutine count in production; use goleak in tests to catch leaks during development

Resources

Comments

👍 Was this article helpful?