Go Binary Size
A stock Go binary carries the runtime, the garbage collector, full symbol and line tables, and every transitively reachable package. 8-15 MiB for a small CLI is normal. Most of it is removable, but only with measurement — guessing which dependency is heavy is almost always wrong.
Procedure
Never apply a flag without a before and after number.
- Build a baseline and record its size.
- Find where the bytes are.
- Apply one change class at a time, measuring after each.
- Verify the binary still runs and its tests still pass.
- Report the table of change → bytes saved → cost.
1. Measure First
# Baseline, reproducible
CGO_ENABLED=1 go build -trimpath -o /tmp/base ./cmd/app
ls -l /tmp/base
# Which packages and symbols cost the most
go tool nm -size -sort size /tmp/base | head -40
# Package-level attribution (third-party, more readable)
go install github.com/Zxilly/go-size-analyzer/cmd/gsa@latest
gsa --web /tmp/base
go version -m /tmp/app prints the module list and build settings baked into
the binary — useful to confirm which flags a release actually used.
Also measure compressed size when the artifact ships in a container layer or a release archive. Stripping wins less after gzip; removing a dependency wins more.
gzip -c /tmp/base | wc -c
2. Strip Symbols and DWARF — the largest single win
go build -ldflags="-s -w" -trimpath -o /tmp/stripped ./cmd/app
Typically 25-35% off the raw size.
What this costs, precisely:
- ✅ Panic messages and goroutine stack traces still work. The runtime uses
its own
pclntab, which-s -wdoes not remove. - ❌
dlvandgdbcan no longer resolve source lines. Do not ship stripped binaries to an environment where you plan to attach a debugger. - ⚠️ Some profiling and crash-reporting tools that symbolise externally will
degrade.
net/http/pprofin-process is unaffected.
Keep an unstripped copy of every release build for post-mortem work.
Add -buildvcs=false when the VCS stamp is not needed. It saves little, but
it also removes commit metadata from a distributed artifact.
3. Disable Inlining — measure the trade
go build -ldflags="-s -w" -gcflags=all=-l -o /tmp/noinline ./cmd/app
Another 5-10 percentage points. It costs runtime performance on hot paths. Acceptable for a CLI that starts, does one thing, and exits. Not acceptable for a latency-sensitive server without benchmarking the regression first.
4. CGO and the Runtime
CGO_ENABLED=0 go build -tags netgo,osusergo -ldflags="-s -w" -o /tmp/pure ./cmd/app
These three go together: disabling cgo without netgo,osusergo leaves the
build depending on the C resolver stubs.
Check before assuming it helps:
go list -deps ./... | xargs go list -f '{{.ImportPath}} {{.CgoFiles}}'shows which packages actually use cgo.- Disabling cgo can increase size when the pure-Go replacement of a C binding is larger. Measure both.
- If the release config already sets
CGO_ENABLED=1or-linkmode=external, there is a reason. Find it before changing it.
When cgo must stay and the project compiles C sources (SQLite bindings, image
codecs), CGO_CFLAGS="-Oz" optimises that C code for size.
5. Build Tags — the step most often skipped
Heavyweight optional features are usually gated behind tags that live outside the Go source.
grep -rn '//go:build' --include='*.go' . | grep -v _test.go
grep -rnE '\-tags' Makefile Taskfile.y*ml .goreleaser.y*ml Dockerfile .github/workflows/ 2>/dev/null
Common wins: dropping a driver you do not use, excluding an admin UI from the
production build, building a noembed variant that fetches assets at runtime.
6. Dependency Weight
A single import can dominate the binary. gsa attributes bytes per module —
start there, not from intuition.
Recurring offenders:
- Cloud provider SDKs. Import the individual service package, never the aggregate root.
github.com/prometheus/client_golangpulls a large surface for a handful of counters.- Anything reflection-heavy: the linker cannot dead-code-eliminate through
reflect, so a reflection-based codec keeps types alive that nothing calls. - Generated protobuf packages for protos you do not use.
Replacing a dependency with 40 lines of standard library is a legitimate size fix. Replacing a well-maintained dependency with your own crypto is not.
7. Embedded Assets
//go:embed content is stored uncompressed.
//go:embed assets/*
var assets embed.FS
Options, in order of preference: ship fewer assets; pre-compress them and
serve with Content-Encoding: gzip; move them out of the binary entirely and
into the container image or a CDN.
8. Container Images
The binary is often the smaller half of the problem.
FROM golang:1.25 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
scratch is smaller than distroless/static but ships no CA certificates,
no /etc/passwd, and no timezone database. Use distroless/static unless
you have verified the binary needs none of them.
9. UPX — last resort, usually wrong
upx --best roughly halves the on-disk size and costs decompression on every
start, breaks mmap-based tooling, and is a strong antivirus and EDR
heuristic trigger. Do not pack a binary that ships to end users or runs in a
monitored production environment. Consider it only for a size-constrained
embedded target, and say so explicitly in the report.
Verification
After every change:
go build -o /tmp/candidate ./cmd/app && /tmp/candidate --version
go test ./...
ls -l /tmp/base /tmp/candidate
A smaller binary that no longer starts, or that lost a feature guarded by a build tag, is not a win.
Verification Checklist
- A baseline size was recorded before any flag changed
- Every claimed saving has a before/after number, raw and compressed
-s -wapplied, and an unstripped artifact retained for debugging-gcflags=all=-lbenchmarked, not assumed, on latency-sensitive codeCGO_ENABLED=0measured both ways, not applied blind- Build tags in Makefile, goreleaser, Dockerfile and CI workflows inspected
- Dependency attribution done with a tool, not from intuition
- The candidate binary runs and the test suite passes
- UPX used only with an explicit justification