Skip to main content
← All Articles Β· Β· 11 min read

Go Performance Optimization: Profiling, Benchmarks & Memory 2026

Go performance optimization guide: pprof CPU/memory profiling, writing benchmarks, escape analysis, reducing allocations, sync.Pool, goroutine leaks detection, and performance testing.

Goperformancepprofbenchmarksmemory optimizationgoroutines

Go is fast out of the box, but production services often have performance headroom you haven’t exploited. This guide walks through systematic Go performance optimization: benchmark first, profile second, fix third. No premature optimization β€” every change is measured.

The Optimization Process

  1. Benchmark β€” establish a baseline, write reproducible measurements
  2. Profile β€” find where time and memory are actually spent
  3. Analyze β€” understand root cause (allocation, syscall, contention)
  4. Fix β€” targeted change based on evidence
  5. Verify β€” measure again, confirm improvement

Writing Benchmarks

Go’s built-in benchmark framework is powerful. Benchmarks live in _test.go files and use testing.B.

package mypackage

import (
    "testing"
    "strings"
    "bytes"
)

// Basic benchmark
func BenchmarkStringConcatenation(b *testing.B) {
    for b.Loop() {  // Go 1.24+: b.Loop() preferred over i < b.N
        var s string
        for j := 0; j < 100; j++ {
            s += "hello"
        }
        _ = s
    }
}

// Better approach
func BenchmarkStringBuilder(b *testing.B) {
    for b.Loop() {
        var sb strings.Builder
        for j := 0; j < 100; j++ {
            sb.WriteString("hello")
        }
        _ = sb.String()
    }
}

// With memory stats
func BenchmarkJSONMarshal(b *testing.B) {
    b.ReportAllocs()  // show allocations per op
    input := generateTestData()

    for b.Loop() {
        data, err := json.Marshal(input)
        if err != nil {
            b.Fatal(err)
        }
        _ = data
    }
}

// Table-driven benchmarks
func BenchmarkHashFunctions(b *testing.B) {
    data := []byte("benchmark input data " + strings.Repeat("x", 1000))

    benchmarks := []struct {
        name string
        fn   func([]byte) uint64
    }{
        {"fnv32", fnv32Hash},
        {"xxhash", xxhash.Sum64},
        {"murmur3", murmur3.Sum64},
    }

    for _, bm := range benchmarks {
        b.Run(bm.name, func(b *testing.B) {
            b.SetBytes(int64(len(data)))
            for b.Loop() {
                _ = bm.fn(data)
            }
        })
    }
}

Running Benchmarks

go test -bench=. ./...                         # run all benchmarks
go test -bench=BenchmarkString -benchmem ./... # specific + memory stats
go test -bench=. -benchtime=5s ./...           # longer run for stable results
go test -bench=. -count=5 ./...                # run 5 times for variance

# Compare before/after with benchstat
go install golang.org/x/perf/cmd/benchstat@latest

go test -bench=. -count=5 > before.txt
# make your change
go test -bench=. -count=5 > after.txt
benchstat before.txt after.txt

benchstat output:

           β”‚ before.txt  β”‚          after.txt          β”‚
           β”‚   sec/op    β”‚   sec/op     vs base         β”‚
String/v1  β”‚  1.234Β΅ Β± 1%β”‚  0.891Β΅ Β± 2%  -27.8% (p=0.001)
String/v2  β”‚  0.912Β΅ Β± 3%β”‚  0.889Β΅ Β± 1%   -2.5% (p=0.042)

pprof CPU Profiling

HTTP Endpoint Profiling

import (
    _ "net/http/pprof"
    "net/http"
    "log"
)

func main() {
    // Enable pprof endpoint on a separate port
    go func() {
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()
    // ... rest of your server
}
# Collect 30-second CPU profile
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30

# Interactive pprof shell
(pprof) top10           # top 10 functions by cumulative time
(pprof) top10 -cum      # sort by cumulative (includes callees)
(pprof) web             # open SVG flame graph in browser
(pprof) list funcName   # show source with annotations
(pprof) traces          # call traces

Benchmark-Based Profiling

# Generate profile from benchmark
go test -bench=BenchmarkFoo -cpuprofile=cpu.prof ./...
go test -bench=BenchmarkFoo -memprofile=mem.prof ./...

# Analyze
go tool pprof cpu.prof
go tool pprof -http=:8080 cpu.prof   # web UI (recommended)

Memory Profiling and Allocation Reduction

Heap Profile

# Capture heap
go tool pprof http://localhost:6060/debug/pprof/heap

(pprof) top10 -inuse_space   # largest in-use allocations
(pprof) top10 -alloc_space   # cumulative allocations since start
(pprof) web inuse_space

Escape Analysis

The compiler moves variables to the heap when it can’t prove they won’t outlive their stack frame. Each heap allocation means GC pressure.

go build -gcflags='-m' ./...           # show escape analysis
go build -gcflags='-m -m' ./...        # verbose (shows reason)

Output:

./handler.go:45:13: &user escapes to heap
./handler.go:62:20: make([]byte, n) does not escape
./handler.go:78:15: string(b) escapes to heap

Reducing escapes:

// Bad: causes heap allocation
func processRequest(r *http.Request) *Result {
    result := Result{}  // might escape
    result.process(r)
    return &result      // taking address forces heap allocation
}

// Better: return by value when small
func processRequest(r *http.Request) Result {
    var result Result
    result.process(r)
    return result       // stack allocated, copied on return
}

// For large structs: pass in a pointer to caller-allocated value
func processRequest(r *http.Request, result *Result) {
    result.process(r)
}

sync.Pool: Reuse Expensive Objects

sync.Pool reduces GC pressure by reusing allocated objects. Use it for frequently allocated, short-lived objects.

import "sync"

var bufferPool = sync.Pool{
    New: func() interface{} {
        return make([]byte, 0, 4096)
    },
}

func processData(data []byte) []byte {
    // Get a buffer from pool
    buf := bufferPool.Get().([]byte)
    buf = buf[:0]  // reset without deallocating
    defer bufferPool.Put(buf)

    // Use the buffer
    buf = append(buf, data...)
    buf = transform(buf)
    result := make([]byte, len(buf))
    copy(result, buf)
    return result
}

// JSON encoder pool
var encoderPool = sync.Pool{
    New: func() interface{} {
        return json.NewEncoder(io.Discard)
    },
}

// bytes.Buffer pool (very common pattern)
var bufPool = sync.Pool{
    New: func() interface{} { return new(bytes.Buffer) },
}

func renderTemplate(tmpl *template.Template, data interface{}) ([]byte, error) {
    buf := bufPool.Get().(*bytes.Buffer)
    buf.Reset()
    defer bufPool.Put(buf)

    if err := tmpl.Execute(buf, data); err != nil {
        return nil, err
    }
    result := make([]byte, buf.Len())
    copy(result, buf.Bytes())
    return result, nil
}

Pool impact:

ScenarioWithout PoolWith PoolImprovement
HTTP handler (1K req/s)45 allocs/op8 allocs/op82%
JSON encoding3.2ms0.8ms75%
GC pause frequencyEvery 500msEvery 2.3s4.6x

Value vs Pointer Receivers

The choice between value and pointer receivers affects allocations and performance.

type Point struct{ X, Y float64 }

// Value receiver: copy of Point on each call
// Good for small structs, immutable operations
func (p Point) Distance(other Point) float64 {
    dx, dy := p.X-other.X, p.Y-other.Y
    return math.Sqrt(dx*dx + dy*dy)
}

// Pointer receiver: no copy, modifies original
// Required for mutation, preferred for large structs
func (p *Point) Scale(factor float64) {
    p.X *= factor
    p.Y *= factor
}

// Rule of thumb:
// - Small structs (<= 2-3 fields): value receiver OK
// - Large structs (> 3 fields): pointer receiver
// - Contains mutex/sync primitives: always pointer receiver
// - Modifies state: always pointer receiver

strings.Builder for String Assembly

// Bad: O(nΒ²) allocations
func joinStrings(parts []string) string {
    result := ""
    for _, p := range parts {
        result += p  // new allocation on each iteration
    }
    return result
}

// Good: single allocation
func joinStrings(parts []string) string {
    var sb strings.Builder
    // Preallocate if you know total size
    total := 0
    for _, p := range parts {
        total += len(p)
    }
    sb.Grow(total)

    for _, p := range parts {
        sb.WriteString(p)
    }
    return sb.String()
}

// Use strings.Join when separator is needed
result := strings.Join(parts, ", ")

Goroutine Leak Detection

Goroutine leaks are a common production issue. Use goleak to catch them in tests.

go get go.uber.org/goleak
func TestMain(m *testing.M) {
    goleak.VerifyTestMain(m)  // fails if goroutines leak
}

func TestMyFunc(t *testing.T) {
    defer goleak.VerifyNone(t)  // per-test check

    // This leaks a goroutine if done channel is never closed
    done := make(chan struct{})
    go func() {
        for {
            select {
            case <-done:
                return
            case <-time.After(1 * time.Second):
                process()
            }
        }
    }()
    // Bug: forgot to close(done) or send to done
}

Common Goroutine Leak Patterns

// Leak: channel send blocks forever
func processItems(items []string) <-chan Result {
    results := make(chan Result)  // unbuffered!
    go func() {
        for _, item := range items {
            results <- process(item)  // blocks if nobody reads
        }
        close(results)
    }()
    return results
}

// Fix: buffered channel or ensure consumer always reads
results := make(chan Result, len(items))

// Leak: goroutine waits on context that's never cancelled
func startWorker(ctx context.Context) {
    go func() {
        select {
        case <-ctx.Done():
            return
        case <-dataChannel:
            process()
        }
    }()
}
// Make sure the context is always eventually cancelled

Context Cancellation for Timeouts

func fetchWithTimeout(url string) ([]byte, error) {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()  // ALWAYS defer cancel to prevent context leak

    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    if err != nil {
        return nil, err
    }

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    return io.ReadAll(resp.Body)
}

Performance Testing with benchstat

# Run benchmarks multiple times (reduces noise)
go test -bench=BenchmarkHandler -benchmem -count=10 > before.txt

# After optimization
go test -bench=BenchmarkHandler -benchmem -count=10 > after.txt

# Statistical comparison
benchstat before.txt after.txt

# Output:
# goos: linux
# goarch: amd64
#                β”‚  before.txt  β”‚         after.txt          β”‚
#                β”‚    sec/op    β”‚    sec/op     vs base        β”‚
# Handler        β”‚  125.4Β΅ Β± 2% β”‚  48.2Β΅ Β± 1%  -61.6% (p=0.000 n=10)
#
#                β”‚  before.txt  β”‚         after.txt          β”‚
#                β”‚   B/op       β”‚   B/op      vs base         β”‚
# Handler        β”‚  2.40Ki Β± 0% β”‚  0.48Ki Β± 0% -80.0% (p=0.000 n=10)
#
#                β”‚  before.txt  β”‚       after.txt            β”‚
#                β”‚  allocs/op   β”‚ allocs/op   vs base         β”‚
# Handler        β”‚  32.00 Β± 0%  β”‚  6.00 Β± 0%  -81.3% (p=0.000 n=10)

The golden rule of Go performance: measure first. The GC and escape analyzer are sophisticated β€” your intuitions about what’s slow are often wrong. Let pprof tell you where to look.

Free Download

50 CLI Commands That Save Hours Every Week

The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.

  • Git mastery (10 cmds)
  • File & search wizardry
  • Docker & DevOps tricks
  • Process & network tools
  • Productivity boosters
  • macOS & Linux install guide

No spam. 1 email/week max. Unsubscribe anytime.

Found this guide useful? Check out our free developer tools.