Go binaries can be surprisingly large, even for simple "hello world" programs, which can bloat your production containers and increase deployment times.
Let’s see a minimal Go program and its typical compiled size:
package main
import "fmt"
func main() {
fmt.Println("Hello, world!")
}
$ go build -o hello main.go
$ ls -lh hello
-rwxr-xr-x 1 user user 1.8M Jan 1 10:00 hello
1.8MB for "Hello, world!" might seem excessive. This is because the Go toolchain, by default, includes a lot of functionality that might not be strictly necessary for your specific application. This includes debugging symbols, extensive runtime support, and even code for features you aren’t using. The goal in production is to strip away everything that isn’t absolutely essential to run your compiled Go program.
Here’s how you can shrink that binary down to its bare minimum:
The most impactful step is to use build tags and linker flags to strip debugging information and disable CGO. CGO allows Go programs to call C code, but it adds significant overhead and complexity, often pulling in system libraries. Unless you explicitly need CGO, disable it.
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o hello_stripped main.go
Let’s break down that command:
CGO_ENABLED=0: This explicitly disables CGO. If you don’t have any C dependencies, this is a safe and significant win.GOOS=linux GOARCH=amd64: While not strictly for size reduction, always specify your target operating system and architecture for reproducible builds, especially when building Docker images.-ldflags="-s -w": These are linker flags:-s: This tells the Go linker to omit the symbol table and debug information. This is a major contributor to binary size.-w: This tells the Go linker to omit the DWARF debug information. DWARF is a standard format for debugging data, and removing it further reduces size.
Now, let’s check the size of our stripped binary:
$ ls -lh hello_stripped
-rwxr-xr-x 1 user user 1.3M Jan 1 10:01 hello_stripped
We’ve already shaved off about 500KB just by disabling CGO and stripping symbols. This is a good starting point for most applications.
For even smaller binaries, you can explore using upx (the Ultimate Packer for Executables), though this is generally not recommended for production containers. UPX is a fantastic tool for compressing executables, but it adds a decompression step at runtime, which can introduce overhead and potential compatibility issues. It’s more for distributing standalone binaries than for inclusion in a dynamic container environment.
# Install upx if you don't have it
# sudo apt-get install upx-ucl # Debian/Ubuntu
# brew install upx # macOS
upx --best hello_stripped -o hello_upxed
ls -lh hello_upxed
-rwxr-xr-x 1 user user 377K Jan 1 10:02 hello_upxed
377KB is impressive, but again, consider the trade-offs of runtime decompression for production.
The most common mistake is forgetting to disable CGO when it’s not needed. This can lead to unexpectedly large binaries, especially if your system has many development libraries installed. If your build process involves docker build and you see large image layers, check if your Go build step is unnecessarily including CGO or debug symbols.
Another common pitfall is not using multi-stage Docker builds. You can build your Go binary in one stage with all the necessary build tools, and then copy only the compiled binary into a minimal final image (like alpine or scratch).
# Stage 1: Build the application
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o app .
# Stage 2: Create the final, minimal image
FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/app .
CMD ["/app/app"]
This Dockerfile ensures that the base image for your running application is as small as possible, containing only the compiled Go binary and the necessary OS libraries (which alpine keeps to a minimum). The scratch image is even smaller, but requires you to statically link your Go binary, which is a more advanced topic.
The next thing you’ll likely encounter when optimizing Go binaries is understanding how to effectively use scratch for truly minimal container images, which often involves static linking and ensuring all dependencies are handled.