You wrote the test. It passed. Half your code still sleeps.
You just finished writing a test for your new HTTP handler. The test passes. Green checkmark. You feel good. Then you realize you never actually tested the error path where the database connection drops. Or maybe you wrote a helper function that sits there doing nothing because you forgot to call it. Passing tests do not guarantee your code runs. They only guarantee the paths you explicitly triggered work. Coverage measurement answers a different question. Which lines of code actually executed when your tests ran?
Coverage is a flashlight, not a judge
Go treats coverage as a first-class tool, not an afterthought. The go test command can instrument your code automatically. It inserts invisible counters next to every executable statement. When your tests run, those counters tick up. The result is a plain text profile that maps source files and line numbers to execution counts. You do not need external agents, complex build scripts, or third-party plugins. The standard toolchain handles it.
Coverage tells you what your tests touch. It does not tell you if your tests are correct. A function can have 100 percent coverage and still contain a logic bug. Coverage is a flashlight, not a judge. Use it to find dead code, untested branches, and forgotten imports. Ignore the percentage. Focus on the gaps.
Go follows a strict naming convention for test files. Any file ending in _test.go belongs to the package_name_test namespace. This isolates test helpers and mock data from production code. The coverage tool respects this boundary. It tracks execution across both production and test files, but you can filter the output to focus only on your application logic. Trust the naming convention. It keeps your coverage reports clean and your imports explicit.
The simplest coverage run
Here is the minimal workflow to generate a coverage report for a single package. You write a tiny package, run the test suite with the -coverprofile flag, and dump the data to a file.
// calculator.go
package calc
// Add returns the sum of two integers.
func Add(a, b int) int {
// Increment the internal counter for this statement block.
return a + b
}
// Subtract returns the difference between two integers.
func Subtract(a, b int) int {
// This line will not execute if no test calls it.
return a - b
}
// calculator_test.go
package calc
import "testing"
// TestAdd verifies the addition function works correctly.
func TestAdd(t *testing.T) {
// Trigger the Add function to mark its lines as executed.
result := Add(2, 3)
// Fail the test if the result does not match expectations.
if result != 5 {
t.Fatal("expected 5")
}
}
Run the test and write the profile to a file. The -coverprofile flag tells the test runner to track execution counts and dump them to the specified path.
go test -coverprofile=coverage.out ./...
The command produces a plain text file. You can read the raw data directly in the terminal, or pipe it to the built-in visualization tools.
go tool cover -func=coverage.out
The -func flag prints a table of every function and its statement coverage percentage. The last line shows the total package coverage. If you want a visual map, open the HTML report.
go tool cover -html=coverage.out
The browser opens a side-by-side view. Green lines executed. Red lines did not. Click a file to jump to its source. The HTML report is static. It captures a snapshot of exactly what ran during that single test invocation.
What happens under the hood
When you add -coverprofile, the Go compiler rewrites your source code before compiling it. It wraps every executable statement in a block that increments a counter. The counter lives in a global array specific to that package. When your test binary starts, the runtime initializes the array. As your test functions call your production code, the counters tick. When the test exits, the runtime serializes the array into the profile file.
The profile format is intentionally simple. Each line contains a file path, a line range, and a count. A line like calculator.go:5.10,6.14 1 1 means lines 5 through 6 executed once. The toolchain parses this mapping and overlays it on your source code. No network calls. No external servers. Just a compiler pass and a text file.
This design keeps coverage fast. You are not running a separate analysis step. You are running your tests with a tiny overhead. The overhead comes from the counter increments and the final serialization. In most projects, the slowdown is negligible. If your test suite takes ten minutes, coverage adds seconds. If your suite takes ten seconds, coverage adds milliseconds.
The compiler also respects Go's formatting conventions during instrumentation. gofmt runs automatically before the coverage pass in most IDE setups. This ensures the line numbers in your profile match the line numbers in your editor. If you skip formatting, the profile still works, but the HTML report might highlight slightly off when you click through. Let the tool decide indentation. Argue logic, not whitespace.
Measuring across packages and dependencies
Real projects rarely live in a single package. You usually want to measure coverage across multiple directories, or track dependencies that your package imports. The -coverpkg flag solves this. It tells the compiler to instrument specific packages, even if they are not the ones being tested directly.
Here is a typical workflow for a multi-package project. You want to ensure your application package covers the internal logic, but you also want to see how much of your database driver wrapper gets exercised.
go test -coverpkg=./...,./internal/db -coverprofile=full.out ./...
The -coverpkg flag accepts a comma-separated list of package patterns. The test runner instruments every package matching those patterns. The ./... pattern at the end tells it which tests to actually execute. The profile file will contain entries for both your application code and the database wrapper.
You can filter the output to ignore generated files or third-party dependencies. The -func flag supports basic filtering, but the HTML report gives you more control. Open the HTML view, click the package dropdown, and toggle visibility. You can also pipe the profile to grep or awk for custom CI reporting.
go tool cover -func=full.out | grep -v "mode: set" | sort -t% -k2 -n
This command strips the header, sorts by percentage, and shows the least covered functions first. It turns the raw data into a prioritized backlog. Fix the red lines. Write the missing tests. Repeat.
Go functions that accept a context should always take it as the first parameter, conventionally named ctx. When you write tests for these functions, pass context.Background() or context.TODO() unless you are specifically testing cancellation. The coverage tool will mark those context-aware branches as executed. If you skip the context parameter in your test, the compiler rejects the program with not enough arguments in call to MyFunction. Coverage cannot measure what does not compile.
Pitfalls, dead code, and false confidence
Coverage measurement has limits. The compiler only tracks statements, not branches. A function with an if statement and an else statement might show 100 percent coverage even if only the if branch executed. The counter increments once for the block, regardless of which path taken. Go 1.20 introduced branch coverage tracking, but it requires the -covermode=atomic or -covermode=set flag and shows up as a separate metric in newer tool versions. Stick to statement coverage unless you explicitly need branch granularity.
Unreachable code inflates your gaps. Dead if conditions, impossible type switches, and commented-out logic that slipped through as empty blocks will show as red. The compiler sometimes catches this with a code is unreachable warning, but not always. If the compiler rejects your program with unreachable code or declared and not used, fix the source before measuring coverage. Coverage reports are useless if your code does not compile.
Test helpers and init functions count toward coverage. If you write a setupTestEnv function that only runs during tests, it will appear in the report. That is expected. The profile tracks execution, not production readiness. Separate your concerns if the test helpers clutter the report. Put them in _test.go files. The coverage tool respects the naming convention and isolates test-only code.
Missing imports break the profile generation. If you run go test -coverprofile=c.out on a package that imports a broken dependency, the compiler stops early. You will see an error like import cycle not allowed or undefined: missingPackage. Fix the build first. Coverage runs on top of a successful compilation.
Do not pass a *string to your functions just to make them easier to test. Strings are already cheap to pass by value. The coverage tool will mark the dereference lines as executed, but you added unnecessary allocation overhead. Keep your signatures simple. Measure what matters.
When to use coverage tools
Use -coverprofile when you need a machine-readable record of test execution for CI pipelines or local analysis. Use -func when you want a quick terminal summary of package-wide coverage percentages. Use -html when you need to visually inspect which specific lines missed execution. Use -coverpkg when you want to measure coverage across imported dependencies or internal packages that are not directly tested. Use plain go test without coverage flags when you are iterating rapidly and want maximum test execution speed. Use branch coverage metrics only when statement coverage leaves critical conditional paths unverified.
Coverage is a map, not a destination. Green lines mean you ran the code. They do not mean the code is correct. Measure gaps. Write tests. Move on.