Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 041 / 100

Benchmarks in Go: a practical guide for developers

Measure before optimizing.

Expona Admin6 min read

Benchmarks in Go: a practical guide for developers

You've just deployed an API that processes user transactions, and it's working—but is it working efficiently? Without measuring, you're flying blind. This article walks through benchmarks in Go: a practical guide for developers, covering how to write benchmarks, run them, compare results, and use realistic inputs to make data-driven optimization decisions.

Why benchmarking matters in production

We've all sat in retrospectives where someone says "we need to optimize this." The problem: optimize what? The database query? The JSON marshalling? The loop that processes items? Benchmarking is your compass. It tells you exactly where time is being spent, and more importantly, it lets you measure whether your optimization actually worked.

In production systems handling thousands of requests per second, small improvements compound. A function called a million times per day that's optimized from 1ms to 0.5ms saves 500 seconds of CPU daily. That's real cost savings in cloud bills and better user experience.

Writing benchmark functions with func BenchmarkX(b *testing.B)

Go's testing package includes a built-in benchmarking framework. A benchmark function follows the pattern func BenchmarkX(b *testing.B), where X is the name of what you're measuring.

Here's a simple example. Imagine we have a function that validates email addresses:

go
package validator

import (
	"regexp"
)

var emailRegex = regexp.MustCompile(`^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$`)

func ValidateEmail(email string) bool {
	return emailRegex.MatchString(email)
}

Now let's write a benchmark for it:

go
package validator

import (
	"testing"
)

func BenchmarkValidateEmail(b *testing.B) {
	email := "user@example.com"
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		ValidateEmail(email)
	}
}

The b.N loop is critical. Go's testing framework runs your benchmark with increasing values of N until it has enough data to get stable results. You don't control N—the framework does. Your job is to call your function inside the loop.

Notice b.ResetTimer(). This is important when you have setup code that shouldn't be timed. For example, if you're benchmarking a database query, you might open a connection before the timer starts:

go
func BenchmarkDatabaseQuery(b *testing.B) {
	db := setupTestDB() // This takes time but isn't what we're measuring
	b.ResetTimer()      // Now start timing
	
	for i := 0; i < b.N; i++ {
		db.QueryUser(1234)
	}
	
	b.StopTimer()
	db.Close() // Cleanup
}

Running benchmarks with go test -bench=. -benchmem

Benchmarks live in _test.go files, just like unit tests. To run them, use go test with the -bench flag:

go
go test -bench=. -benchmem

The -bench=. runs all benchmarks matching the pattern (dot means all). The -benchmem flag tells Go to also report allocations, which is crucial for understanding garbage collection pressure.

Here's typical output:

Benchmark Iterations ns/op B/op allocs/op
BenchmarkValidateEmail-8 2000000 587 ns/op 0 B/op 0 allocs/op

Breaking this down:

  • 587 ns/op: Each operation takes 587 nanoseconds on average.
  • 0 B/op: Zero bytes allocated per operation.
  • 0 allocs/op: Zero allocations per operation (the regex was pre-compiled).

Compare this with a less efficient version that compiles the regex inside the function:

go
func BenchmarkValidateEmailBad(b *testing.B) {
	for i := 0; i < b.N; i++ {
		re := regexp.MustCompile(`^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$`)
		re.MatchString("user@example.com")
	}
}

Running this would show something like 45000 ns/op with dozens of allocations. That's 75x slower just because of recompilation. This is exactly why benchmarking catches real issues.

Use -benchtime to change how long each benchmark runs:

go
go test -bench=. -benchmem -benchtime=5s

And -benchstat (available via golang.org/x/perf/cmd/benchstat) to compare runs systematically.

Comparing results with benchstat

Running a benchmark once gives you a number, but that number can fluctuate due to system noise, garbage collection, CPU frequency scaling, and scheduler variability. This is why benchstat exists.

First, install it:

go
go install golang.org/x/perf/cmd/benchstat@latest

Run your benchmark multiple times and save the output:

go
go test -bench=. -benchmem -count=10 > baseline.txt

Make your optimization. Run again:

go
go test -bench=. -benchmem -count=10 > optimized.txt

Compare with benchstat:

go
benchstat baseline.txt optimized.txt

Output might look like:

Benchmark old ns/op new ns/op delta
BenchmarkValidateEmail-8 587 542 -7.67%

The delta shows whether the change was statistically significant. benchstat uses statistical analysis to filter out noise, so you're not fooled by random fluctuations.

Benchmark realistic inputs

Here's where many engineers go wrong: they benchmark with toy data. A JSON parser that's fast on a small object might have terrible allocation patterns on the payloads your API actually receives.

Let's say you're benchmarking JSON unmarshalling for an order object:

go
type Order struct {
	ID       int64
	UserID   int64
	Items    []OrderItem
	Metadata map[string]string
	Total    float64
}

type OrderItem struct {
	SKU      string
	Quantity int
	Price    float64
}

func BenchmarkUnmarshalOrder(b *testing.B) {
	// This is too small and not representative
	data := []byte(`{"id":1,"userID":1,"items":[],"metadata":{},"total":0}`)
	
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		var order Order
		json.Unmarshal(data, &order)
	}
}

Instead, create a fixture that matches production reality:

go
func BenchmarkUnmarshalOrder(b *testing.B) {
	// Realistic: order with 10 items, custom metadata
	data := []byte(`{
		"id":123456,
		"userID":789,
		"items":[
			{"sku":"PROD-001","quantity":2,"price":29.99},
			{"sku":"PROD-002","quantity":1,"price":149.99},
			{"sku":"PROD-003","quantity":5,"price":9.99}
		],
		"metadata":{
			"source":"mobile_app",
			"campaign":"summer_sale",
			"promo_code":"SAVE20",
			"utm_source":"google"
		},
		"total":299.95
	}`)
	
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		var order Order
		json.Unmarshal(data, &order)
	}
}

func BenchmarkUnmarshalOrderWithLargeMetadata(b *testing.B) {
	// Worst case: large metadata like customer notes
	data := []byte(`{
		"id":123456,
		"userID":789,
		"items":[...],"metadata":{"notes":"` + strings.Repeat("A", 5000) + `"},"total":299.95
	}`)
	
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		var order Order
		json.Unmarshal(data, &order)
	}
}

Create multiple benchmarks for different scenarios: empty data, typical data, large data, pathological data. This gives you the full picture. You might find that your code is fast for normal orders but degrades badly when metadata gets large.

Common mistakes

Not resetting the timer: Setup code gets included in the measurement, making your benchmark slower and less useful.

Using small input sizes: Allocators and cache behaviors differ dramatically between small and large datasets. Always test with realistic sizes.

Benchmarking without -benchmem: Speed is only half the story. A function that's 10% slower but allocates half the memory is often a win in production because it reduces GC pressure.

Making one-shot comparisons: System noise is real. Always run benchmarks multiple times with -count=10 or more. A 5% improvement that benchstat shows as statistically insignificant isn't an improvement—it's noise.

Optimizing before benchmarking: This is the biggest mistake. You end up optimizing the wrong thing. Measure first. Always.

Key takeaways

  • Write benchmarks using func BenchmarkX(b *testing.B) and let Go's framework control b.N—don't hardcode iteration counts.
  • Always use -benchmem to see allocation counts; high allocation rates often matter more than raw speed in production.
  • Run benchmarks multiple times (-count=10) and use benchstat to compare before and after, filtering out system noise and confirming statistical significance.
  • Create realistic benchmark inputs that match your actual production payloads; toy data gives you false confidence.
  • Use b.ResetTimer() to exclude setup costs and b.StopTimer() for cleanup, ensuring you measure only what matters.
  • Measure before optimizing—blind optimization wastes time and often makes things worse.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles