Profiling, performance and deployment
pprof for CPU and memory, escape analysis and allocations, the execution tracer, GOMAXPROCS in containers, and shipping static binaries.
Profiling with pprof
// 1. in a test: measure one function against its inputs
// go test -bench=BenchmarkEncode -cpuprofile cpu.out -memprofile mem.out ./...
// go tool pprof -http=:9090 cpu.out
// 2. in a running service: expose the standard endpoints
import _ "net/http/pprof"
go func() {
// bind to localhost or a private port; never expose this to the internet
slog.Info("pprof", "err", http.ListenAndServe("127.0.0.1:6060", nil))
}()
// go tool pprof http://127.0.0.1:6060/debug/pprof/profile?seconds=30
// go tool pprof http://127.0.0.1:6060/debug/pprof/heap| Profile | Answers |
|---|---|
profile (CPU) | Where the processor time actually went over a sampling window |
heap | What is still allocated, and which call sites allocated it |
allocs | Every allocation ever made, including short-lived ones — the one that finds hot loops |
block | Where goroutines wait on channels, mutexes and selects |
mutex | Lock contention, with the holders responsible |
goroutine | A full stack dump of every goroutine — the fastest way to find a leak |
- Profile first, then optimise. Intuition about hot spots is wrong often enough that a profiling session is always cheaper than a guessed rewrite.
- Read a profile top-down for cumulative cost, then bottom-up for the leaf function that does the work. A high flat cost in
runtime.mallocgcis an allocation problem, not a CPU problem. - A CPU profile from a 30-second window is far more trustworthy than one from two seconds; sampling noise dominates short runs.
Allocations and escape analysis
go build -gcflags='-m' ./... # report escape analysis decisions
go test -bench=. -benchmem ./... # ns/op, B/op, allocs/op// before: allocates a new slice on every call, and grows it repeatedly
func NamesBad(users []User) []string {
var out []string // nil, no capacity: repeated growth
for _, u := range users {
out = append(out, u.Name)
}
return out
}
// after: one allocation, sized exactly once
func NamesGood(users []User) []string {
out := make([]string, 0, len(users)) // one allocation, no copy growth
for _, u := range users {
out = append(out, u.Name)
}
return out
}
// strings.Builder avoids the quadratic cost of repeated concatenation
func Join(parts []string) string {
var b strings.Builder
b.Grow(len(parts) * 16) // a hint, not a requirement
for _, p := range parts {
b.WriteString(p)
}
return b.String()
}
// sync.Pool recycles buffers that are expensive to allocate
var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}- A value escapes when the compiler cannot prove it stays local: taking its address, returning a pointer, storing it in a slice or an interface. Knowing why a value escapes tells you which rewrite removes the allocation.
- Passing a large struct by value copies it; passing a pointer may make it escape to the heap. Measure both before choosing.
- Preallocate when the final size is known —
make([]T, 0, n)— and prefer a single append of a complete slice over many small ones. sync.Poolsuits a small number of large buffers, such as an encode target. It is not a general cache and anything in it may be collected at any time.- Do not optimise a cold path. A microsecond saved in a function that runs once at startup is a net negative if it makes the code harder to read.
Shipping: tracer, containers and cross-compilation
# execution tracer: scheduling, GC, syscalls over a window
go test -trace trace.out ./...
go tool trace trace.out
# one static binary, no interpreter, no shared libraries
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o dist/notes-linux-amd64 ./cmd/notes
# stamp the version into the binary
go build -ldflags "-X main.version=$(git describe --tags --always)" ./cmd/notesFROM golang:1.24 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -o /out/app ./cmd/notes
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]- Use the execution tracer for latency questions a CPU profile cannot answer: scheduling delays, GC pauses, blocked goroutines, GOMAXPROCS pressure.
- Modern Go reads the container CPU limit, so a 2-CPU quota yields GOMAXPROCS=2 automatically. Set it explicitly only when the quota is wrong or the runtime cannot see it, and remember
GOMAXPROCSis not a memory limit. - The container memory limit is important: set
GOMEMLIMITa little below it so the collector works harder instead of the kernel killing the process. -trimpathremoves local paths from the binary, and-s -wdrops the symbol table and DWARF data for a smaller image. Both make debugging harder, so keep an un-stripped build for crash analysis.- Publish a reproducible artifact: tag, build in CI from that tag, attach the binary and checksums to the release, and roll forward rather than rebuilding an old tag in place.
⚠️
Do not expose
net/http/pprof on a public port. The endpoints leak memory contents, function names and file paths, and the profile endpoints let any caller spend your CPU for thirty seconds at a time. Bind it to localhost or a private listener.FAQ
How do I find a goroutine leak?
Fetch
/debug/pprof/goroutine?debug=1 twice, a minute apart, and look at the stacks that grow. Usually a goroutine is blocked on a channel send or receive with no cancellation path, or an HTTP body was never closed.Is a bigger GOMAXPROCS always faster?
No. Above the number of real cores, goroutines contend for the same CPU and the scheduler does more work. It is also irrelevant to memory: use
GOMEMLIMIT for the collector, not GOMAXPROCS.Related
Packages, project layout and dependencies Testing, benchmarking and fuzzing
Last refreshed 2026-09-18.