| name | go-performance-review |
| description | Detect performance anti-patterns and apply optimization techniques in Go. Covers allocations, string handling, slice/map preallocation, sync.Pool, benchmarking, and profiling with pprof. Use when checking performance, finding slow code, reducing allocations, profiling, or reviewing hot paths. Trigger examples: "check performance", "find slow code", "reduce allocations", "benchmark this", "profile", "optimize Go code". Do NOT use for concurrency correctness (use go-concurrency-review) or general code style (use go-coding-standards).
|
| license | MIT |
| metadata | {"version":"1.0.0"} |
Go Performance Review
Profile first, optimize second. Never optimize without a benchmark proving the problem.
1. Allocation Reduction
Prefer strconv over fmt for primitive conversions:
s := strconv.Itoa(42)
s := strconv.FormatFloat(3.14, 'f', 2, 64)
s := fmt.Sprintf("%d", 42)
Avoid unnecessary string-to-byte conversions:
var b strings.Builder
for _, s := range parts {
b.WriteString(s)
}
result := b.String()
result := ""
for _, s := range parts {
result += s
}
Preallocate slices and maps when size is known:
users := make([]User, 0, len(ids))
for _, id := range ids {
users = append(users, getUser(id))
}
lookup := make(map[string]User, len(users))
var users []User
Use sync.Pool for frequently allocated, short-lived objects:
var bufPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
func process(data []byte) string {
buf := bufPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset()
bufPool.Put(buf)
}()
buf.Write(data)
return buf.String()
}
2. Hot Path Optimizations
Avoid interface conversions in tight loops:
func sum(vals []int64) int64 {
var total int64
for _, v := range vals {
total += v
}
return total
}
func sum(vals []interface{}) int64 { ... }
Avoid reflect in performance-critical paths:
If you need reflection-like behavior at scale, use code generation
(go generate, stringer, protocol buffers).
Reduce pointer chasing:
type Points struct {
X []float64
Y []float64
}
type Points []*Point
3. Map Performance
m := make(map[string]int, expectedSize)
4. Benchmarking
ALWAYS write benchmarks before and after optimization:
func BenchmarkFoo(b *testing.B) {
input := generateInput()
b.ResetTimer()
for i := 0; i < b.N; i++ {
result = Foo(input)
}
}
var result string
Run benchmarks with memory profiling:
go test -bench=BenchmarkFoo -benchmem -count=5 ./...
Compare before/after with benchstat:
go test -bench=. -count=10 > old.txt
go test -bench=. -count=10 > new.txt
benchstat old.txt new.txt
5. Profiling
CPU profiling:
go test -cpuprofile=cpu.prof -bench=BenchmarkFoo .
go tool pprof cpu.prof
Memory profiling:
go test -memprofile=mem.prof -bench=BenchmarkFoo .
go tool pprof -alloc_space mem.prof
HTTP server profiling (import net/http/pprof):
import _ "net/http/pprof"
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
6. High-Throughput Logging
log/slog is the right default for most services. But when benchmarks show
logging is a bottleneck (high-frequency hot paths, >100k log lines/sec),
consider zero-allocation loggers.
When slog is not enough:
slog.Info("request handled",
slog.String("method", method),
slog.Int("status", status),
)
logger, _ := zap.NewProduction()
logger.Info("request handled",
zap.String("method", method),
zap.Int("status", status),
)
Decision tree:
| Scenario | Logger |
|---|
| General service logging | log/slog (stdlib, zero dependencies) |
| High-frequency hot path (>100k lines/sec) | go.uber.org/zap (zero-alloc) |
| Extreme throughput with JSON | github.com/rs/zerolog (zero-alloc JSON) |
Best of both worlds — use zap as slog backend:
zapLogger, _ := zap.NewProduction()
slogHandler := zapslog.NewHandler(zapLogger.Core(), nil)
logger := slog.New(slogHandler)
logger.Info("request handled",
slog.String("method", method),
slog.Int("status", status),
)
Logging anti-patterns in hot paths:
for _, item := range millions {
slog.Info("processing item", slog.String("id", item.ID))
process(item)
}
for i, item := range millions {
process(item)
if i%10000 == 0 {
slog.Info("progress", slog.Int("processed", i), slog.Int("total", len(millions)))
}
}
slog.Info("batch complete", slog.Int("count", len(millions)))
NEVER switch loggers without a benchmark proving the need.
slog is fast enough for the vast majority of Go services.
7. Common Anti-Patterns
| Anti-Pattern | Fix |
|---|
fmt.Sprintf for simple int→string | strconv.Itoa |
| String concatenation in loop | strings.Builder |
| Slice without preallocation | make([]T, 0, n) |
| Map without capacity hint | make(map[K]V, n) |
regexp.Compile inside function | Compile once at package level |
json.Marshal in hot path | Use code-gen (easyjson, sonic) |
| Logging in tight loop | Batch or sample |
defer in very tight inner loop | Manual cleanup (rare, benchmark first) |
Important Caveat
Most Go code is not performance-critical. Readability and correctness ALWAYS
take priority over micro-optimizations. Only apply these patterns when:
- A benchmark proves this code path is a bottleneck
- The optimization is significant (>10% improvement)
- The resulting code remains readable and maintainable
Premature optimization is still the root of all evil, even in Go.