These three topics become much easier once you separate the questions they answer:
| Topic | Main question | Main Go tool | Result |
|---|---|---|---|
| Testing | Does my code work correctly? | go test | PASS / FAIL |
| Benchmarking | How fast is this code? | go test -bench | ns/op, allocations |
| Profiling | Where is my program spending CPU/memory? | go tool pprof | Hot functions / bottlenecks |
A useful analogy:
- Testing = examiner → “Is the answer correct?”
- Benchmarking = stopwatch → “How long does it take?”
- Profiling = heat map → “Where is the time being spent?”
Go has built-in support for all three, so the examples below require no third-party testing or profiling library. go test, the testing package, runtime/pprof, and go tool pprof are part of the Go toolchain. (Go.dev)
0. Before starting
Verify Go:
go version
That’s essentially all the installation you need for this tutorial.
There is:
- no testing framework to install
- no benchmark package to install
- no profiler to install
- no configuration file needed
For each example we’ll create a tiny Go module using:
go mod init ...
1. Testing
What?
Testing checks whether your program produces the correct result.
For example:
2 + 3 should equal 5
A test automatically calls your function and checks that expectation.
In Go, test files normally end with:
_test.go
and test functions use *testing.T. The go test command discovers and runs them automatically. (Go.dev)
Why?
Without automated tests, you might change some code and accidentally break something that worked yesterday.
Testing gives you confidence that:
change code
↓
run tests
↓
still works
When?
Run tests:
- while developing
- after changing code
- before committing code
- before releasing software
- after fixing bugs
Where?
Normally tests sit beside the code they test:
testing-demo/
├── go.mod
├── calculator.go
└── calculator_test.go
How?
Step 1 — Create the project
mkdir testing-demo
cd testing-demo
go mod init example.com/testing-demo
Complete example
calculator.go
package calculator
// Add returns the sum of two integers.
func Add(a, b int) int {
return a + b
}
calculator_test.go
package calculator
import "testing"
func TestAdd(t *testing.T) {
// Call the real function.
got := Add(2, 3)
// This is the result we expect.
want := 5
// Compare actual result with expected result.
if got != want {
t.Fatalf("Add(2, 3) = %d; want %d", got, want)
}
}
That’s the entire test.
Run it
go test
You should see something similar to:
PASS
ok example.com/testing-demo
For more detail:
go test -v
Example:
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
PASS
ok example.com/testing-demo
-v requests verbose test output. (Go Packages)
Understand the important lines
This creates a test
func TestAdd(t *testing.T)
Think:
Test + FunctionName
Call your real code
got := Add(2, 3)
got means:
What did my program actually produce?
Define what you expect
want := 5
want means:
What should the program produce?
Compare them
if got != want {
If:
got = 5
want = 5
everything is fine.
If:
got = 4
want = 5
the test fails.
Fail the test
t.Fatalf(...)
This tells Go:
STOP — this test failed
Testing mental model
Remember:
INPUT
↓
FUNCTION
↓
ACTUAL RESULT
↓
compare
↓
EXPECTED RESULT
Testing cares about correctness, not speed.
Testing commands to remember
# Run tests in current package
go test
# Detailed output
go test -v
# Test every package below current directory
go test ./...
Go’s tooling recognizes _test.go files and runs compatible test functions through go test. (Go.dev)
2. Benchmarking
Now suppose your function is correct.
Next question:
How fast is it?
That is benchmarking.
What?
A benchmark repeatedly executes some code and measures its performance.
Instead of:
Does Add() return the correct answer?
you ask:
How long does BuildMessage() take?
Why?
Imagine you have two implementations:
Version A → 5 milliseconds
Version B → 1 millisecond
Both may produce exactly the same answer.
Testing cannot tell you which is faster.
Benchmarking can.
When?
Use benchmarking when:
- optimizing code
- comparing two implementations
- investigating performance
- checking allocations
- evaluating a performance change
Don’t benchmark everything automatically.
Benchmark code where performance actually matters.
Where?
Benchmarks also normally live in _test.go files.
Example:
benchmark-demo/
├── go.mod
├── message.go
└── message_test.go
Benchmark functions use:
func BenchmarkSomething(b *testing.B)
The go test -bench=. command runs benchmarks; benchmarks are not run by default. (Go.dev)
Complete example
Step 1 — Create project
mkdir benchmark-demo
cd benchmark-demo
go mod init example.com/benchmark-demo
message.go
package message
import "strings"
// BuildMessage creates a string containing "Go"
// repeated n times.
func BuildMessage(n int) string {
var builder strings.Builder
for i := 0; i < n; i++ {
builder.WriteString("Go")
}
return builder.String()
}
message_test.go
package message
import "testing"
// Store the result so the compiler cannot simply
// throw away the work being benchmarked.
var benchmarkResult string
func BenchmarkBuildMessage(b *testing.B) {
for i := 0; i < b.N; i++ {
benchmarkResult = BuildMessage(100)
}
}
Run the benchmark
go test -bench=.
You may see something like:
BenchmarkBuildMessage-8 1200000 850 ns/op
PASS
Your numbers will probably be different.
That’s expected.
CPU, OS, Go version and machine workload all affect benchmark results.
The mysterious b.N
This confuses many people initially:
for i := 0; i < b.N; i++ {
You don’t normally choose b.N.
Go’s benchmark runner controls it.
Conceptually:
Go: Try 1 iteration
Go: Too quick.
Go: Try more iterations
Go: Still quick.
Go: Try many iterations
Go: Good. Now I have enough data.
So your benchmark simply says:
for i := 0; i < b.N; i++ {
doThingBeingMeasured()
}
Measure memory too
Run:
go test -bench=. -benchmem
-benchmem tells go test to print memory-allocation statistics for benchmarks. (Go Packages)
You might see:
BenchmarkBuildMessage-8 1200000 850 ns/op 224 B/op 1 allocs/op
Read it approximately like this:
| Value | Meaning |
|---|---|
1200000 | Number of benchmark iterations |
850 ns/op | About 850 nanoseconds per operation |
224 B/op | About 224 bytes allocated per operation |
1 allocs/op | About 1 allocation per operation |
Benchmarking mental model
FUNCTION
↓
run
run
run
run
run
run
↓
measure
↓
time/op
memory/op
allocations/op
Benchmarking cares about performance measurements.
It does not tell you why something is slow.
That brings us to profiling.
Benchmark commands to remember
# Run all benchmarks
go test -bench=.
# Benchmark + memory allocations
go test -bench=. -benchmem
The . in:
-bench=.
is a regular expression matching all benchmark names. Go’s current test command documentation explicitly describes -bench=. as the way to run all benchmarks. (Go.dev)
3. Profiling
This is the one people usually confuse with benchmarking.
Suppose your benchmark tells you:
This operation takes 500 ms.
Great.
But why does it take 500 ms?
Where is that time going?
Function A?
Function B?
JSON parsing?
Database work?
String processing?
Garbage collection?
Profiling helps answer that.
What?
Profiling observes a running program and identifies where resources are being consumed.
For CPU profiling:
Program uses lots of CPU
↓
Profiler samples execution
↓
Function A → 5%
Function B → 10%
Function C → 75%
↓
Function C is suspicious
Go provides runtime profiling data that can be examined with go tool pprof. CPU profiles tell you where the program spends CPU time. (Go.dev)
Why?
Benchmarking tells you:
The operation is slow.
Profiling tells you:
This particular function is consuming most of the CPU.
That’s the critical difference.
When?
Use profiling when:
- an application is slow
- CPU usage is high
- memory usage is high
- you know there is a performance problem but don’t know where it is
- benchmarks show regression and you need the cause
Where?
Profiling is normally performed against a realistic workload.
For learning, we’ll intentionally create a CPU-heavy program and profile it.
Our folder:
profile-demo/
├── go.mod
└── main.go
Complete CPU profiling example
Create project
mkdir profile-demo
cd profile-demo
go mod init example.com/profile-demo
main.go
package main
import (
"fmt"
"log"
"os"
"runtime/pprof"
"time"
)
// isPrime intentionally performs CPU work.
// The profiler should identify this as expensive.
func isPrime(n int) bool {
if n < 2 {
return false
}
for divisor := 2; divisor*divisor <= n; divisor++ {
if n%divisor == 0 {
return false
}
}
return true
}
// countPrimes calls isPrime many times.
func countPrimes(limit int) int {
count := 0
for n := 2; n <= limit; n++ {
if isPrime(n) {
count++
}
}
return count
}
func main() {
// Create the file where CPU profiling data will be stored.
file, err := os.Create("cpu.prof")
if err != nil {
log.Fatal(err)
}
// Start collecting CPU profiling data.
if err := pprof.StartCPUProfile(file); err != nil {
log.Fatal(err)
}
// Run CPU-heavy work for about 2 seconds.
deadline := time.Now().Add(2 * time.Second)
result := 0
runs := 0
for time.Now().Before(deadline) {
result += countPrimes(50000)
runs++
}
// Stop profiling so all profiling data is written.
pprof.StopCPUProfile()
if err := file.Close(); err != nil {
log.Fatal(err)
}
fmt.Printf("Completed %d runs; result=%d\n", runs, result)
fmt.Println("CPU profile written to cpu.prof")
}
The important profiling calls are:
pprof.StartCPUProfile(file)
and:
pprof.StopCPUProfile()
Stopping the profile before the program exits is important so pending profiling information can be written. This is the standard pattern documented by Go. (Go.dev)
Step 1 — Build the program
go build -o profile-demo .
You’ll now have:
profile-demo
Step 2 — Run it
./profile-demo
Example:
Completed 42 runs; result=215292
CPU profile written to cpu.prof
You’ll now see:
profile-demo/
├── go.mod
├── main.go
├── profile-demo
└── cpu.prof
cpu.prof contains the profiling information.
Step 3 — Analyze the profile
Here is our profiling tool:
go tool pprof
Run:
go tool pprof -top ./profile-demo cpu.prof
The documented pprof command accepts the program binary and profile data for analysis. (Go Packages)
You should see output roughly resembling:
Showing nodes accounting for 1.92s, 96% of 2.00s total
flat flat% sum% cum cum%
1.70s 85.00% 85.00% 1.70s 85.00% main.isPrime
0.15s 7.50% 92.50% 1.85s 92.50% main.countPrimes
Your exact numbers will differ.
The important part is:
main.isPrime
If that function consumes most of the CPU, you have discovered your hotspot.
Understanding flat and cum
Two useful columns are:
flat
CPU time spent directly inside that function.
Example:
main.isPrime 1.70s
cum
Cumulative time:
function itself
+
functions it called
You don’t need to memorize every pprof column yet.
Initially ask:
Which functions are at the top?
That’s enough to get started.
Profiling mental model
PROGRAM
│
▼
┌────────────────┐
│ CPU activity │
└────────────────┘
│
▼
runtime/pprof
│
▼
cpu.prof
│
▼
go tool pprof
│
▼
HOT FUNCTIONS
Profiling cares about where resources are being spent.
Go’s diagnostics documentation describes profiling specifically as a way to identify expensive or frequently executed parts of a program. (Go.dev)
The three together
This is the workflow I recommend remembering:
flowchart TD
A[Write Go code] --> B[Testing]
B --> C{Correct?}
C -->|No| D[Fix code]
D --> B
C -->|Yes| E[Benchmarking]
E --> F{Performance good?}
F -->|Yes| G[Done]
F -->|No| H[Profiling]
H --> I[Find hotspot]
I --> J[Optimize hotspot]
J --> B
Notice something important:
Profiling does not replace benchmarking. Benchmarking does not replace testing.
They solve different problems.
Testing vs Benchmarking vs Profiling
| Testing | Benchmarking | Profiling | |
|---|---|---|---|
| Main purpose | Correctness | Measure performance | Find performance bottleneck |
| Main question | Does it work? | How fast is it? | Where is time/resources going? |
| Standard Go tool | go test | go test -bench | go tool pprof |
| Go API | testing.T | testing.B | runtime/pprof |
| Typical file | _test.go | _test.go | application code/profile |
| Output | PASS/FAIL | ns/op | hotspots |
| Memory info | Not primary purpose | -benchmem | memory profiles possible |
| Runs code repeatedly? | Usually a defined test case | Yes, many times | Observes executing program |
| Finds bugs? | Yes | Not primarily | Not primarily |
| Finds slow code? | No | Tells you something is slow | Tells you where it is slow |
| Best time to use | Constantly | Performance work | Performance investigation |
One example situation
Imagine an HTTP endpoint:
GET /products
Users say it is slow.
First: Testing
You test:
Does /products return the correct products?
Result:
PASS
So correctness looks good.
Second: Benchmarking
You benchmark:
How long does product processing take?
Result:
50 ms/op
That’s slower than you’d like.
But you still don’t know why.
Third: Profiling
Profile the program.
You discover:
parseProducts() 5%
sortProducts() 80%
formatResponse() 10%
other 5%
Now you know:
sortProducts()
is where you should investigate.
That’s the relationship between the three.
The commands you actually need to memorize
For now, memorize only these six:
Testing
go test
go test -v
go test ./...
Benchmarking
go test -bench=.
go test -bench=. -benchmem
Profiling
go tool pprof -top ./program cpu.prof
That’s enough for a solid foundation.
Final mental cheat sheet
When confused, ask yourself which question you are trying to answer:
"IS MY CODE CORRECT?"
│
▼
TEST
go test
"HOW FAST IS MY CODE?"
│
▼
BENCHMARK
go test -bench=.
"WHY/WHERE IS IT SLOW?"
│
▼
PROFILE
go tool pprof
Or even shorter:
Testing = correctness
Benchmarking = measurement
Profiling = investigation
Recommended learning order
Don’t try to master all three simultaneously. Learn them in exactly this sequence:
1. testing.T
↓
2. testing.B
↓
3. runtime/pprof + go tool pprof
Once that distinction is completely clear, more advanced topics such as table-driven tests, mocks, coverage, subtests, benchmark comparisons, memory profiling, HTTP pprof, tracing, race detection, and fuzzing become much easier to understand.