Go Performance Profiling: pprof, Benchmarks, and Memory Optimization
Performance optimization without measurement is guesswork. Go ships with a world-class profiling ecosystem—pprof, built-in benchmarks, execution tracing—that makes systematic optimization approachable. This guide walks through the complete workflow from identifying bottlenecks to validating improvements.
Setting Up pprof
Go's net/http/pprof package exposes profiling endpoints over HTTP, making it trivial to profile production services:
package main
import (
"log"
"net/http"
_ "net/http/pprof"
)
func main() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
runServer()
}
For non-HTTP applications, use programmatic profiling:
func main() {
cpuFile, _ := os.Create("cpu.prof")
defer cpuFile.Close()
pprof.StartCPUProfile(cpuFile)
defer pprof.StopCPUProfile()
doWork()
memFile, _ := os.Create("mem.prof")
defer memFile.Close()
pprof.WriteHeapProfile(memFile)
}
Collecting Profiles
# CPU profile: 30 seconds of sampling
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
# Heap (memory) profile
go tool pprof http://localhost:6060/debug/pprof/heap
# Goroutine dump
go tool pprof http://localhost:6060/debug/pprof/goroutine
# Execution trace
curl -o trace.out http://localhost:6060/debug/pprof/trace?seconds=5
go tool trace trace.out
Analyzing with pprof Interactive Shell
$ go tool pprof cpu.prof
(pprof) top10 # show top 10 functions by CPU time
(pprof) top10 -cum # cumulative time (including callees)
(pprof) list processItem # show annotated source
(pprof) web # open flamegraph in browser
(pprof) png > cpu.png # save graph as image
Reading pprof output:
- flat: time spent in the function itself
- flat%: percentage of total time
- cum: time in function + its callees
- cum%: cumulative percentage including callees
Writing Effective Benchmarks
Go's testing package includes a benchmarking framework:
package stringutil_test
import (
"strings"
"testing"
)
func BenchmarkStringConcat(b *testing.B) {
for i := 0; i < b.N; i++ {
s := ""
for j := 0; j < 100; j++ {
s += "x"
}
_ = s
}
}
func BenchmarkStringBuilder(b *testing.B) {
for i := 0; i < b.N; i++ {
var sb strings.Builder
for j := 0; j < 100; j++ {
sb.WriteString("x")
}
_ = sb.String()
}
}
func BenchmarkMemAlloc(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
s := make([]byte, 1024)
_ = s
}
}
// Table-driven benchmark
func BenchmarkReverseString(b *testing.B) {
cases := []struct {
name string
input string
}{
{"short", "hello"},
{"medium", strings.Repeat("hello", 100)},
{"long", strings.Repeat("hello", 10000)},
}
for _, tc := range cases {
b.Run(tc.name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = reverseString(tc.input)
}
})
}
}
Run benchmarks:
go test -bench=. -benchmem ./...
go test -bench=BenchmarkStringBuilder -benchmem -count=5
# Compare before/after with benchstat
benchstat old.txt new.txt
Escape Analysis
When a variable "escapes" to the heap, it requires garbage collection. Use escape analysis to understand allocation patterns:
go build -gcflags="-m" ./...
go build -gcflags="-m -m" ./.. # more verbose
// Does NOT escape: small value returned by value
func noEscape() [3]int {
return [3]int{1, 2, 3}
}
// ESCAPES to heap: returned pointer outlives function
func escapes() *int {
x := 42 // "x escapes to heap" reported by -gcflags=-m
return &x
}
// Common escape trigger: interface boxing
func interfaceEscape() {
var x int = 42
var i interface{} = x // x may escape
_ = i
}
sync.Pool for Reducing Allocations
sync.Pool recycles objects to reduce GC pressure:
var bufferPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
func processRequest(data []byte) string {
buf := bufferPool.Get().(*bytes.Buffer)
buf.Reset()
defer bufferPool.Put(buf)
buf.WriteString("processed: ")
buf.Write(data)
return buf.String()
}
Avoiding Common Allocation Hotspots
String Conversions
// BAD: unnecessary []byte -> string conversion
func badStringConversion(b []byte) bool {
s := string(b)
return strings.HasPrefix(s, "prefix")
}
// GOOD: use bytes package directly
func goodBytesCheck(b []byte) bool {
return bytes.HasPrefix(b, []byte("prefix"))
}
Slice Pre-allocation
// BAD: repeated growth causes multiple allocations
func buildSliceBad(n int) []int {
var result []int
for i := 0; i < n; i++ {
result = append(result, i)
}
return result
}
// GOOD: pre-allocate with known size
func buildSliceGood(n int) []int {
result := make([]int, 0, n)
for i := 0; i < n; i++ {
result = append(result, i)
}
return result
}
Map Pre-sizing
// GOOD: hint the initial capacity
func buildMapGood(keys []string) map[string]int {
m := make(map[string]int, len(keys))
for i, k := range keys {
m[k] = i
}
return m
}
Real-World Optimization Case Study
Systematic approach to optimizing a hot path:
// Original: JSON parsing in hot loop
func processEvents(raw [][]byte) []Event {
var events []Event
for _, data := range raw {
var e Event
json.Unmarshal(data, &e)
events = append(events, e)
}
return events
}
// Optimized: pre-allocate slice
func processEventsOptimized(raw [][]byte) []Event {
events := make([]Event, 0, len(raw))
for _, data := range raw {
var e Event
if err := json.Unmarshal(data, &e); err != nil {
continue
}
events = append(events, e)
}
return events
}
Using sync.Pool for encoder reuse:
var encoderPool = sync.Pool{
New: func() interface{} {
return &bytes.Buffer{}
},
}
func encodeJSON(v interface{}) ([]byte, error) {
buf := encoderPool.Get().(*bytes.Buffer)
buf.Reset()
defer encoderPool.Put(buf)
enc := json.NewEncoder(buf)
if err := enc.Encode(v); err != nil {
return nil, err
}
result := make([]byte, buf.Len())
copy(result, buf.Bytes())
return result, nil
}
Performance Optimization Checklist
- Measure first: Never optimize without profiling data.
- Focus on hot paths: A few functions usually account for most CPU time.
- Reduce allocations: Use
b.ReportAllocs()in benchmarks; target zero allocs in hot paths. - Pre-size collections: Hint map and slice sizes when known.
- Use sync.Pool: Recycle short-lived, frequently allocated objects.
- Avoid interface{}: Boxing causes heap allocations; use generics or concrete types.
- Profile in production: Development machines don't reflect production workloads.
- Validate improvements: Use
benchstatto confirm statistically significant gains.
With Go's excellent tooling, performance optimization becomes a data-driven discipline. Profile, identify the bottleneck, make a targeted change, measure again. Repeat until you meet your SLOs.