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
ProfileAnswers
profile (CPU)Where the processor time actually went over a sampling window
heapWhat is still allocated, and which call sites allocated it
allocsEvery allocation ever made, including short-lived ones — the one that finds hot loops
blockWhere goroutines wait on channels, mutexes and selects
mutexLock contention, with the holders responsible
goroutineA 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.mallocgc is 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.Pool suits 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/notes
FROM 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 GOMAXPROCS is not a memory limit.
  • The container memory limit is important: set GOMEMLIMIT a little below it so the collector works harder instead of the kernel killing the process.
  • -trimpath removes local paths from the binary, and -s -w drops 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.

Packages, project layout and dependencies Testing, benchmarking and fuzzing

Last refreshed 2026-09-18.