1. Why benchmarks are needed
If you have ever optimized code “by eye,” then you already know the fantasy genre. In your head it seems that strings.Builder is definitely faster than concatenation; that one sort is “probably fine”; that allocations are “not that scary.” And then it turns out you did not optimize the bottleneck—you just made the code less readable.
A benchmark is a small test that runs the target piece of code many times and measures how much one operation costs on average. Important: a benchmark does not prove that code is “good.” It helps you compare two approaches under the same workload and stop arguing “in the chat” about who is right.
Where a benchmark lives: BenchmarkXxx(b *testing.B)
Structurally, a benchmark is very similar to a normal test: it lives in a *_test.go file and is run with go test. This is not a separate “space profiler”—it is part of Go’s standard testing toolkit.
By analogy with how go test finds and runs Example... functions in *_test.go, benchmarks follow a similar rule: the function name must start with Benchmark and it must accept *testing.B.
The skeleton looks like this:
package taskapp
import "testing"
func BenchmarkSomething(b *testing.B) {
// measured code goes here
}
The name after Benchmark is the benchmark’s name. That is exactly what you will later use to choose what to run via -bench.
2. Iterations: b.N and b.Loop()
b.N and why we do not write “1000 times”
When a beginner sees a benchmark, they often expect something like: “run it 1,000,000 times and measure.” But Go is smarter: it picks the number of iterations itself so that the measurement is long enough and stable. For this, *testing.B has the b.N field: the number of iterations the runner chose for this run.
All you need to do is honestly perform the measured operation b.N times:
package taskapp
import "testing"
func BenchmarkAdd(b *testing.B) {
x := 0
for i := 0; i < b.N; i++ {
x += i
}
_ = x
}
Why is this better than a “fixed number”? Because for fast operations 1000 iterations is too few (the time will be noisy), and for slow ones it is too many (you will wait forever). The runner balances this automatically.
for b.Loop() {}
Modern Go has improvements that make benchmarks less error-prone. For example, starting with Go 1.24, benchmarks can use testing.B.Loop() instead of a manual loop over b.N. This is intended as a faster and less “fragile” way to structure iterations.
If you see code like this, do not be scared:
package taskapp
import "testing"
func BenchmarkNormalizeTitleLoop(b *testing.B) {
raw := " BUY MILK "
b.ResetTimer()
for b.Loop() {
_ = NormalizeTitle(raw)
}
}
Semantically it is the same thing: perform the measured action the required number of times, as determined by the runner. It is just a more “direct” notation and it usually reduces the chance of getting loop boundaries wrong.
In this lecture we will still confidently stick to the classic for i := 0; i < b.N; i++, because it appears in a huge amount of code and it is important to be able to read it.
4. Timer: b.ResetTimer, b.StopTimer, b.StartTimer
b.ResetTimer(): how not to measure data setup instead of the algorithm
One of the most common mistakes is to accidentally measure something other than what you intended. For example, you want to measure string search speed, but in reality you are measuring test data generation, file reading, building a huge array, or some other “preparatory” work.
That is why benchmarks have an important ritual: first do the setup, then say “okay, measure from here” via b.ResetTimer().
Let’s imagine that in our training app (a hypothetical taskapp) we have task title normalization: trim spaces at the edges and convert to a consistent form. Let’s write a simple function:
package taskapp
import "strings"
func NormalizeTitle(s string) string {
s = strings.TrimSpace(s)
return strings.ToLower(s)
}
Now a benchmark with test-string preparation:
package taskapp
import (
"strings"
"testing"
)
var sinkString string // "sink" for the result (explained below)
func BenchmarkNormalizeTitle(b *testing.B) {
// setup: prepare input
raw := strings.Repeat(" BUY MILK ", 100)
b.ResetTimer() // now measure only NormalizeTitle
for i := 0; i < b.N; i++ {
sinkString = NormalizeTitle(raw)
}
}
The idea of b.ResetTimer() is simple: everything before it is considered setup and must not affect the final numbers.
When ResetTimer is not enough: StopTimer() and StartTimer()
Sometimes data preparation is needed not only “once before the loop,” but, for example, inside each iteration (and you want to exclude that preparation from measurements). In such cases you use b.StopTimer() and b.StartTimer().
The core principle is the same: you should measure the part you are drawing conclusions about. If you need to “turn off the stopwatch” during preparation time, you can do that.
If you find yourself frequently juggling StopTimer/StartTimer, that is a signal you are measuring a scenario that is too complex for a microbenchmark.
5. Running and parameters: -bench, -run, -benchtime
-bench as a filter
On their own, Benchmark... functions do not necessarily run during a regular go test. Typically you run them explicitly: you tell the test runner which benchmarks to run using the -bench flag.
Example commands (for reference):
go test -bench=.
Here . means “all benchmarks.” You can also choose a specific benchmark or a group by name, because -bench takes a pattern (a regular expression in the go test style).
To avoid also running all unit tests in parallel (which can simply take a long time), people often add -run with a test name that definitely does not exist:
go test -run TestDoesNotExist -bench=BenchmarkNormalizeTitle
The meaning is: “do not run tests, but do run the benchmark.” This is useful when you have many tests and right now you only care about measurements.
-benchtime: when numbers “jump around”
Sometimes you run a benchmark and see the results fluctuate: 50 ns/op one time, 70 ns/op the next. That is partly normal: the system is not obligated to be perfectly stable (background load, scheduler, cache warm-up, CPU frequency changes).
The -benchtime flag lets you tell the runner: “measure longer” (or, conversely, shorter). The longer the measurement, the lower the relative noise. Therefore, when you compare two close-in-speed variants, it is useful to increase the duration.
Example (for reference):
go test -run TestDoesNotExist -bench=BenchmarkNormalizeTitle -benchtime=2s
It is important to understand the philosophy: -benchtime is not a “computer accelerator.” It is a request for the runner to collect statistics longer so the conclusion is more reliable.
How to read benchmark output
When you run a benchmark, Go prints lines roughly like this:
BenchmarkNormalizeTitle-8 3000000 420 ns/op
This line is useful to read as: “on my machine (and with this level of parallelism/CPU), one operation costs N nanoseconds on average.” The iteration count (3000000) is not your goal; it is just what the runner decided to run in order to gather statistics.
If you compare two approaches, do not draw conclusions from a single run. Run it multiple times and check whether one variant is consistently better. And if the difference is within the noise, you might be trying to optimize something that is not a problem in the first place.
6. Sink variable: how not to measure compiler optimization
This is the classic story: you wrote a benchmark, and the compiler looked at it and said, “Hmm, the result is not used anywhere. That means we can throw the computation away as dead code.” And you happily measure almost nothing.
To prevent this, you need to make the result “observable”: store it in a variable that lives long enough and that the compiler cannot easily ignore. The simplest option is a package-level variable:
package taskapp
import "testing"
var sinkInt int
func BenchmarkSum(b *testing.B) {
s := 0
for i := 0; i < b.N; i++ {
s += i
}
sinkInt = s
}
Yes, it looks like a “magic global.” But it is specifically a technical technique for measurement. In normal business code we would not do this, but in benchmarks it is standard practice.
7. Mini-scenario: benchmarking output string formatting
In apps like a “task manager” there is often code that builds an output string: one task → one row in a table/log. It is not necessarily a “bottleneck,” but it is a convenient example because it is clear and easy to measure.
Let’s make two versions: a “naive” one using + and a more careful one using strings.Builder. We are not learning a new package here—we are just measuring the effect.
package taskapp
import "strconv"
type Task struct {
ID int
Title string
Done bool
}
func FormatTaskPlus(t Task) string {
return "#" + strconv.Itoa(t.ID) + " " + t.Title
}
And the Builder version:
package taskapp
import (
"strconv"
"strings"
)
func FormatTaskBuilder(t Task) string {
var b strings.Builder
b.WriteByte('#')
b.WriteString(strconv.Itoa(t.ID))
b.WriteByte(' ')
b.WriteString(t.Title)
return b.String()
}
Now the benchmarks, where we honestly separate data preparation:
package taskapp
import "testing"
var sinkFmt string
func BenchmarkFormatTaskPlus(b *testing.B) {
t := Task{ID: 42, Title: "buy milk", Done: false}
b.ResetTimer()
for i := 0; i < b.N; i++ {
sinkFmt = FormatTaskPlus(t)
}
}
func BenchmarkFormatTaskBuilder(b *testing.B) {
t := Task{ID: 42, Title: "buy milk", Done: false}
b.ResetTimer()
for i := 0; i < b.N; i++ {
sinkFmt = FormatTaskBuilder(t)
}
}
Then you compare the results and no longer “take my word for it,” or the internet’s, or the inner optimizer voice whispering, “Let’s rewrite everything in assembly...”. No. Not today.
8. Cheat sheet for the main tools
| What it is | Where it is used | Meaning |
|---|---|---|
|
|
Benchmark entry point |
|
|
How many iterations the runner requested |
|
|
Do not measure preparation; start measuring “from a clean slate” |
|
|
Do not measure “in-flight” preparation |
|
|
Which benchmarks to run |
|
|
How long to collect statistics (approximately) |
|
|
Prevent the compiler from eliminating computations |
9. Common mistakes when writing benchmarks
Mistake #1: measuring setup instead of the target operation.
This happens quietly: inside the benchmark you generate a large input, read a file, create a huge slice—and all of that honestly ends up in the measurement. The fix is almost always the same: move preparation before the loop and put b.ResetTimer() right before the measured part.
Mistake #2: forgetting to use the result and “measuring emptiness.”
If the computation result is not stored anywhere, the compiler is allowed to optimize your code so it becomes cheaper than you think. As a result you get pretty numbers that are not about your algorithm, but about how cleverly the compiler removed dead work. Fix it with a sink variable: the result must be written somewhere.
Mistake #3: printing or logging inside the measured loop.
fmt.Println inside for i := 0; i < b.N; i++ turns the benchmark into a console-output speed meter rather than a measurement of your function. If you want to “see” what is happening, it is better to write a separate unit test or an Example..., and keep the benchmark as quiet as possible.
Mistake #4: comparing results of different benchmarks with different inputs.
One benchmark formats a short string, another formats a long one; one uses n=10, another uses n=100000. Then you conclude “the second one is slower.” Yes, slower—because you gave it more work. To compare algorithms, the input must be the same and fixed.
Mistake #5: drawing conclusions from a single run and without -benchtime when the difference is small.
When you are chasing a difference of a few percent, system noise can be comparable to the effect. In such cases, a longer measurement via -benchtime and repeatability help. And sometimes the best conclusion sounds like this: “the difference is negligible; let’s keep the more readable code.”
GO TO FULL VERSION