Go Performance Tips: Making Your Code Faster
Go is fast. But it's not magic—you can still write slow Go code. Here's what I learned optimizing Sysmon-CLI and Zvezda.
Profile Before You Optimize
Your intuition about what's slow is wrong. Mine was wrong. Everyone's is wrong.
go test -bench=. -benchmem
go test -cpuprofile=cpu.prof
go tool pprof cpu.profFor Sysmon-CLI, I spent a week optimizing metric collection before profiling showed that JSON serialization was the actual bottleneck. Whoops.
The pprof web interface (`-http=:8080`) is fantastic. Use it.
Memory is Your Enemy
Allocations kill performance. The GC is good, but not allocating beats any GC.
**Preallocate slices when you know the size:**
// Don't do this
var results []string
for _, item := range items {
results = append(results, process(item))
}
// Do this
results := make([]string, 0, len(items))
for _, item := range items {
results = append(results, process(item))
}That tiny `len(items)` capacity hint prevents multiple allocations as the slice grows.
**Use sync.Pool for short-lived objects:**
var bufPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
func formatMetric(m Metric) string {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
defer bufPool.Put(buf)
// Use buf, then return it to pool
return buf.String()
}In Sysmon-CLI, this cut allocations by 80% in the hot path.
**String concatenation in loops is terrible:**
// Allocates on every iteration
var result string
for _, s := range parts {
result += s
}
// One allocation
var builder strings.Builder
builder.Grow(estimatedSize) // if you know approximate size
for _, s := range parts {
builder.WriteString(s)
}
result := builder.String()Goroutines Aren't Free
"Just spawn a goroutine" is bad advice. Goroutines are cheap but not free.
**Use worker pools for bounded concurrency:**
func processConcurrently(items []Item, workerCount int) {
jobs := make(chan Item, len(items))
var wg sync.WaitGroup
// Fixed number of workers
for i := 0; i < workerCount; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for item := range jobs {
process(item)
}
}()
}
// Feed work
for _, item := range items {
jobs <- item
}
close(jobs)
wg.Wait()
}Zvezda uses this pattern when scanning multiple repos. 4-8 workers is usually optimal—more doesn't help.
**Don't leak goroutines:**
func worker(ctx context.Context, jobs <-chan Job) {
for {
select {
case job, ok := <-jobs:
if !ok {
return // channel closed
}
process(job)
case <-ctx.Done():
return // context cancelled
}
}
}Leaked goroutines are memory leaks. They pile up. Use context for cancellation.
I/O: Batch and Buffer
**Database operations should be batched:**
// Don't
for _, record := range records {
db.Exec("INSERT INTO ...", record)
}
// Do
tx := db.Begin()
stmt, _ := tx.Prepare("INSERT INTO ...")
for _, record := range records {
stmt.Exec(record)
}
tx.Commit()One transaction instead of N. Massive speedup.
**Buffer file I/O:**
file, _ := os.Open("large.log")
reader := bufio.NewReader(file)
// Now reads are bufferedUnbuffered I/O is syscalls on every read. Buffered I/O batches them. Easy win.
What Doesn't Matter (Usually)
**Reflection is slow.** True. But if you're doing it once at startup, who cares? Don't use code generation until profiling proves it's worth it.
**Function call overhead.** The compiler inlines aggressively. Small functions are fine. Don't flatten your code for "performance"—the compiler is smarter than you.
There's no `//go:inline` directive. The compiler decides. You can see what gets inlined with:
go build -gcflags='-m' 2>&1 | grep inline**Integer vs float math.** Float operations are slightly slower, but unless you're doing millions of them in a tight loop, you won't notice.
Profiling is Your Friend
**CPU profiling:**
go test -cpuprofile=cpu.prof -bench=.
go tool pprof -http=:8080 cpu.prof**Memory profiling:**
go test -memprofile=mem.prof -bench=.
go tool pprof -http=:8080 mem.prof**Trace for concurrency issues:**
go test -trace=trace.out
go tool trace trace.outThe trace tool shows goroutine scheduling, blocking, and contention. It's how I found that Zvezda was spending 40% of time waiting on a mutex.
Benchmarking
Write benchmarks for your hot paths:
func BenchmarkMetricCollection(b *testing.B) {
collector := NewCollector()
b.ResetTimer()
for i := 0; i < b.N; i++ {
collector.Collect()
}
}Run with:
go test -bench=. -benchmem -benchtime=10sCompare before/after optimizations with `benchstat`.
Sysmon-CLI Case Study
Sysmon-CLI collects system metrics every second. Initial version used 50MB RAM and 5% CPU.
After optimization:
Final: ~8MB RAM, <1% CPU on typical machines.
The key: profile, optimize the hot path, measure again. Repeat.
When NOT to Optimize
Don't optimize if:
Readability beats premature optimization. Always.
The Tools I Use
**pprof**: CPU, memory, goroutine, mutex profiling. Built into Go.
**benchstat**: Compare benchmark results statistically.
**trace**: Visualize concurrency and scheduling.
**Sysmon-CLI**: Monitor the actual production system. CPU spikes? Memory leaks? You need to see them in real-time.
Final Thoughts
Go is fast out of the box. Most code doesn't need optimization.
When you do need speed:
1. Profile first
2. Fix the hot path
3. Benchmark the change
4. Profile again
And remember: the fastest code is code you don't run. Sometimes the best optimization is algorithmic, not tactical.