How to Profile Compile Times in Go

Profile Go compile times by running go build with -gcflags to see timing breakdowns for each compilation phase.

How to Profile Compile Times in Go

You hit enter on go build and the terminal freezes. Three seconds pass. In the world of Go, three seconds feels like an eternity. Your CI pipeline is set to timeout after thirty seconds, and you're watching the progress bar crawl. You need to know which package is eating the CPU cycles. Go doesn't hand you a compile-time profiler with flame graphs. The toolchain assumes builds are fast. When they aren't, you diagnose the slowness by inspecting the build graph, the cache state, and the command stream.

Go's build system is built around a content-addressable cache. The compiler hashes every input: source files, dependencies, compiler version, environment variables, and even the exact flags passed to the tool. If the hash matches a cached result, the build skips the work entirely. Profiling compile times usually means finding why the cache isn't helping, or identifying a package so large that even a fresh compile takes too long.

The build cache is the performance feature

Most Go builds return instantly because nothing changed. The build system computes a hash for every package. This hash includes the source code, the import graph, the compiler version, the target architecture, and the flags. If you change a single character in a file, the hash changes. The cache misses. The build system recomputes the hash for every file. If you have thousands of files, the hashing overhead can add up, though it's usually negligible compared to compilation.

The cache lives in $GOCACHE. On Linux and macOS, this defaults to $HOME/.cache/go-build. The cache stores compiled objects keyed by the action hash. An action includes the source code and the exact command used to compile it. If you change a flag, the action changes. The cache treats go build and go build -race as different actions. The -race flag adds instrumentation. The resulting binary is different. The cache stores both versions.

Convention aside: The build cache respects gofmt. If you format your code differently, the hash changes. Most editors run gofmt on save. This keeps the cache stable. Don't argue about indentation; let the tool decide. Consistent formatting prevents accidental cache misses caused by whitespace drift. Trust gofmt. Argue logic, not formatting.

Inspect the build order with verbose output

Run the build with verbose output to see the package order. The -v flag prints each package name as the build system processes it. This output reveals the build order. Packages with no dependencies appear first. Packages that depend on others appear later. If you see a package name and the terminal hangs for several seconds before printing the next one, that package is the bottleneck.

# Verbose output lists packages as they are processed.
# This reveals the build order and helps spot large packages.
# Packages that take long to compile will pause the output stream.
go build -v ./...
# output:
myproject/internal/utils
myproject/internal/huge
myproject/cmd/server

The output shows myproject/internal/huge last. If the pause happens before myproject/cmd/server, the huge package is the culprit. You can isolate the package and measure it directly. Run go build ./internal/huge to compile just that package. If it's still slow, the package has too many files or complex types.

Diagnose commands and artifacts

When verbose output isn't enough, you need to see the commands. The -x flag prints the shell commands the build system runs. This output shows the exact flags passed to the compiler. It also shows the temporary directory where the compiler writes intermediate files. You can inspect the size of the package by looking at the command arguments.

# -x prints the commands go runs.
# Look for long-running compile steps or repeated work.
# The output includes the path to the compiler and all flags.
go build -x ./cmd/server
# output:
WORK=/tmp/go-build12345
cd /home/user/project
/usr/local/go/pkg/tool/linux_amd64/compile -o $WORK/b001/_pkg_.a -trimpath $WORK/b001=/tmp/go-build12345/b001 -p myproject/cmd/server -lang=go1.21 -complete -buildid ABC123 -D _/home/user/project -importcfg $WORK/b001/importcfg -pack ./cmd/server/main.go

The WORK variable points to the temporary directory. You can use go build -work to keep the temporary directory after the build finishes. This lets you inspect the intermediate files. The directory contains object files, assembly output, and metadata. If a package is huge, the object files will be large. You can also see if the compiler is running multiple times for the same package, which indicates a cache miss or a broken dependency.

Convention aside: The build system prints verbose output like if err != nil is verbose. The community accepts the boilerplate because it makes the unhappy path visible. The -x output is noisy by design. It shows every step. Use it to find the step that's slow. Don't ignore the noise; the signal is in the details.

Enumerate dependencies and trace imports

Sometimes the slowness comes from dependencies. A single import can pull in a massive library. You need to see the full dependency tree. The go list command enumerates packages. Use -deps to list all dependencies. Pipe the output to wc -l to count them. If you have thousands of dependencies, the build graph is deep. The compiler has to resolve types across many packages.

# List all dependencies for the main package.
# Count the lines to estimate the size of the dependency graph.
# A large number suggests heavy imports or transitive dependencies.
go list -deps ./cmd/server | wc -l

If you find a suspicious dependency, trace why it's included. The go mod why command explains why a package is in the build. It shows the import chain from your code to the package. This helps you remove unused dependencies. Removing a dependency reduces the build graph. It also reduces the binary size.

# Trace why a specific package is included.
# This shows the import chain from your code to the package.
# Use this to find unexpected heavy imports.
go mod why -m github.com/heavy/library

Convention aside: Public names start with a capital letter. Private names start lowercase. Dependencies follow the same rule. If you import a package with a capital letter, it's public. If you import a package with a lowercase letter, it's private to the module. Use go mod why to check if you're importing public packages you don't need. Accept interfaces, return structs. If you only need an interface, import the interface package, not the implementation. This keeps the dependency graph lean.

Pitfalls and compiler errors

The source text mentions -gcflags="-m -l". This is a common confusion. The -m flag enables escape analysis. It prints information about where variables are allocated. It does not print timing. The -l flag disables inlining. It forces the compiler to skip inlining functions. Using -l can actually make the build faster because inlining analysis takes time, but it hurts runtime performance. If you pass -gcflags="-m", the compiler floods stderr with escape analysis output. It doesn't show time.

The compiler rejects unknown flags with flag provided but not defined: -t. If you try -gcflags="-t", you get an error. There is no -t flag for timing. The compiler also rejects invalid escape analysis flags with invalid value -m for flag -gcflags: flag provided but not defined. If you pass -gcflags="-m" to a package that doesn't support it, you might get a warning.

Another pitfall is go tool compile -V. This command prints the compiler version. It does not profile. It's useful for checking which version is running, but it won't help with timing. The compiler version affects the cache. If you upgrade Go, the cache invalidates. The build takes longer because everything recompiles. This is expected. The cache is keyed by the compiler version.

Goroutine leaks happen when a goroutine waits on a channel that never closes. Similarly, build cache leaks happen when the cache grows unbounded. The cache has a size limit, usually 10GB. If you run out of disk space, the build fails. Use go clean -cache to reset the cache if it gets corrupted. The worst goroutine bug is the one that never logs. The worst build bug is the one that silently corrupts the cache.

Convention aside: _ discards a value intentionally. result, _ := ... says "I considered the second return value and chose to drop it". Use it sparingly with errors. In build scripts, don't discard errors. If a command fails, the build should fail. Use set -e in bash scripts. The build system expects errors to be fatal. Don't swallow errors in your build scripts.

Decision matrix

Use go build -v when you need to identify which packages are being compiled and in what order. Use go build -x when you need to inspect the exact compiler commands and flags being passed to the toolchain. Use go list -deps when you need to enumerate the full dependency tree to find unexpected heavy imports. Use go mod why when you need to trace why a specific package is included in the build. Use go clean -cache when you suspect the build cache is corrupted and causing unnecessary rebuilds. Use go build -work when you need to inspect temporary build artifacts and intermediate files. Use plain go build when the build is fast; profiling adds overhead and obscures the actual issue if the cache is working correctly.

The cache is your friend. Trust the hash. Go builds are fast. If it's slow, something is wrong. Don't fight the cache. Keep your flags stable.

Where to go next