Skip to content
shellhacks.dev
Go

Why Go Binaries Are Large and How to Shrink Them Explained

Why Go binaries are large and how to shrink them: ldflags -s -w, trimpath, UPX trade-offs, dropping heavy dependencies and measuring what takes space.

6 min read
Summarize in AI
Illustration for "Why Go Binaries Are Large and How to Shrink Them Explained": a terminal session, a directory tree, test results.

A command line tool in Go compiles to a single file with no dependencies, which makes it easy to ship. The cost is size: a trivial program is a couple of megabytes, and a tool with an HTTP client and a few libraries is 10 to 20. Whether that matters depends on where it ships, and reducing it is cheap. It follows from static binaries and cross compiling. The numbers below are indicative, from Go 1.23 on Linux amd64.

See what you have

Shell
cat > main.go <<'EOF'
package main

import "fmt"

func main() { fmt.Println("hello") }
EOF
go build -o hello .
ls -l hello
go version -m hello | head -3
Text
-rwxr-xr-x 1 user user 1.5M Nov  3 10:30 hello
hello: go1.23.2
	path	example.test/hello
	mod	example.test/hello	(devel)

The 1.5 MB comes from the runtime (scheduler, garbage collector, memory allocator) plus parts of the standard library the program touches, and the symbol and debug tables. The figures are approximate and vary by version and platform.

Strip symbols and paths

Shell
go build -ldflags="-s -w" -trimpath -o hello-small .
ls -l hello hello-small
Text
-rwxr-xr-x 1 user user 1.5M Nov  3 10:30 hello
-rwxr-xr-x 1 user user 1.1M Nov  3 10:31 hello-small

-s omits the symbol table and -w omits DWARF debug information. The binary still runs and panics still print stack traces, but a debugger such as dlv has less to show. -trimpath replaces build-machine paths with module paths in stack traces, which also keeps builds reproducible. Keep an unstripped build in CI artifacts for debugging.

What really grows a binary

Shell
go install github.com/Zxilly/go-size-analyzer/cmd/gsa@latest
gsa hello-small
go tool nm -size -sort size hello | head

Dependencies dominate. Importing net/http adds TLS, HTTP/2 and crypto (about 3 to 4 MB), a large logging or CLI framework adds more, and reflect-heavy packages such as templates and JSON encoders pull in code that cannot be pruned. Use a tool like go tool nm or a size analyzer to see which packages take space, then decide whether a lighter alternative exists. Replacing a heavy flag library with flag from the standard library can save a megabyte; see comparing CLI libraries.

UPX and its trade-offs

Shell
upx --best hello-small -o hello-upx
ls -l hello-upx

UPX compresses the executable and unpacks it into memory at start, often halving the size again. The costs are real: slower start-up (tens of milliseconds, more on slow disks), higher memory use because pages cannot be shared, trouble with some antivirus tools and no debuggability. It makes sense for something downloaded once over a slow link, not for a CLI invoked thousands of times or a container image where layers already compress.

Reading the size breakdown

Before shrinking a binary, find out what is in it. Tools such as go tool nm -size -sort size list symbols by size, and third-party analysers like goweight or go-binsize-treemap group the size by package. A typical Go binary contains the runtime, the standard library packages it uses, and its dependencies, with the standard library's networking and TLS code, the net/http stack and reflection-heavy packages such as encoding/json and fmt among the largest. Seeing the numbers directs the effort: removing a 3 MB dependency that is used for one function does more than any compiler flag.

Compare builds on the same platform, with the same flags, since the size varies with the operating system and architecture. Record the baseline in the repository so a size regression stands out in review.

Linker flags and their effect

The flags -ldflags="-s -w" strip the symbol table and DWARF debugging information, usually removing a quarter to a third of the file. The cost is that stack traces still show function names, which come from a different table, but debuggers lose symbols, and tools like pprof need the separate data. Keep an unstripped build for debugging and ship the stripped one. The flag -trimpath removes local file system paths from the binary, which improves reproducibility and privacy and saves a little space. Build with the current Go release, since the compiler and linker improve over time, and some versions reduce size without any change on your side.

Setting CGO_ENABLED=0 yields a static binary with no C library dependency, which affects size and portability, and it is the common choice for container images.

Dependencies and code choices

The largest savings usually come from what you import. A large framework pulled in for one helper brings its dependency tree along. Check with go mod why and go list -deps which packages come in and why. Replace heavy libraries with smaller ones or with standard library code where the cost is low. Reflection and packages that use it prevent the linker from removing unused code, since it cannot tell which methods might be called. Avoid importing net/http in a tool that does not need a network. Build tags can compile out optional features, producing a smaller variant for constrained environments. Each change should be measured, because the effect of a given import varies and intuition is a poor guide.

Embedding large assets with embed adds their bytes to the binary. Compress them first, or download them at run time if the tool may do so.

Compression and its price

UPX compresses the executable and decompresses it in memory at start, which can cut the file to a third or less. The costs are a slower start, since the decompression runs each time, higher memory use because the pages cannot be shared from the file, trouble with some antivirus programs that flag packed executables, and incompatibility with certain platforms and security features. For a tool downloaded once and run often, the trade is poor. It makes sense for distribution over slow links or tight storage, such as an embedded device. In containers, the image layers are compressed anyway, so UPX adds little to the transfer size. Before choosing, set the actual requirement: a download under 10 MB, a container image under a certain size, a limit on a device. If the unstripped build is already within it, stop; the engineering time is better spent elsewhere, and a simple binary is easier to debug, sign and support than a clever one.

Add a size check to CI that fails the build when the binary grows beyond a threshold you chose, such as ten percent over the last release. Growth then has to be explained in the pull request, which is the right moment to notice that a new dependency doubled the size. Publish the size with each release, so users on constrained systems can plan, and keep the analysis commands in the contributing notes for whoever investigates next.

Where this does not apply

On a server or a laptop, 10 MB is irrelevant, and effort spent shrinking is wasted. It matters for containers pulled to many nodes, embedded devices, serverless cold starts and downloads on poor links. In a container image, a binary on scratch or distroless is already small compared with an OS base; the options are in choosing a base image. For release builds, GoReleaser can set the flags once. See the linker flags reference and the Go diagnostics guide.

Taggedgo
Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.