brainstorming
brainstorming
You MUST use this before any creative work - creating features, building components, adding functionality, modifying behavior, or planning anything new, in software or out of it (a talk, a business, a renovation).
go-binary-sizeReduce the size of compiled Go binaries and container images: linker flags, inlining budget, CGO and build tags, embedded assets, dependency weight, and measuring what actually costs bytes. Use when a binary or image is too large, when shrinking a CLI for distribution, or when auditing what a dependency adds to the build. Trigger examples: "binary is too bi…
Independent GitHub snapshot · Automated classification · Original documentation governs
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.
Never apply a flag without a before and after number.
# 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
go build -ldflags="-s -w" -trimpath -o /tmp/stripped ./cmd/app
Typically 25-35% off the raw size.
What this costs, precisely:
pclntab, which -s -w does not remove.dlv and gdb can no longer resolve source lines. Do not ship stripped
binaries to an environment where you plan to attach a debugger.net/http/pprof in-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.
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.
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.CGO_ENABLED=1 or
-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.
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.
A single import can dominate the binary. gsa attributes bytes per module —
start there, not from intuition.
Recurring offenders:
github.com/prometheus/client_golang pulls a large surface for a handful
of counters.reflect, so a reflection-based codec keeps types alive that nothing calls.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.
//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.
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.
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.
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.
-s -w applied, and an unstripped artifact retained for debugging-gcflags=all=-l benchmarked, not assumed, on latency-sensitive codeCGO_ENABLED=0 measured both ways, not applied blind