Skip to content

$ less ~/blog/go-performance-tips.md

Go Performance Tips: Making Your Code Faster

Real performance tips from building Sysmon-CLI and Zvezda. No theory—just what actually made things 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.prof

For 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 buffered

Unbuffered 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.out

The 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=10s

Compare 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:

  • Preallocated metric buffers: -30MB
  • sync.Pool for temp objects: -15MB
  • Worker pool instead of goroutine-per-metric: -3% CPU
  • Buffered channel for metric reporting: -1% CPU

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:

  • It's not in your profiler's top 10
  • It runs once at startup
  • It makes the code unreadable
  • You're guessing instead of measuring

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.

(END) — press q… or just go back to ~/blog

more in technical/

Independent studio building developer tools, local-first software and custom projects.

contact@nf-software.com
© 2026 NF Software · SIRET 104 780 820 00015privacyterms
NORMAL~/blog/go-performance-tipsTop