How to Reduce Go Binary Size (ldflags, UPX, strip)

Shrink Go binaries by stripping debug symbols with ldflags and compressing with UPX.

When your binary weighs more than your code

You finish writing a Go service. It handles requests, connects to a database, and passes every test. You run go build and check the file size. Fifteen megabytes. You need to ship it to a fleet of edge devices with limited storage, or you want your CI pipeline to finish in seconds instead of minutes. The binary feels bloated. It is bloated. Go compiles to statically linked executables by default, which means the runtime, the standard library, and all your dependencies are baked directly into the file. That design choice gives you one binary that just works anywhere. It also gives you a lot of extra weight you might not need in production.

Why Go executables are heavy

A Go executable is not just your code. It is a complete system. The compiler packs the garbage collector, the scheduler, the standard library, and your application into a single file. During development, the linker also attaches a symbol table and DWARF debug information. The symbol table maps memory addresses back to function names and file paths. DWARF is a standardized format that stores line numbers, variable names, and type information so debuggers can reconstruct your program state. These features are essential when you are stepping through a panic or attaching a profiler. They are dead weight when the program is running in production. Stripping removes that dead weight. Compression shrinks what remains.

The baseline build

Start with the simplest possible program to see how much space the runtime actually takes.

// main.go
package main

import "fmt"

// Main prints a message to establish a baseline binary size
func main() {
    fmt.Println("Hello, world")
}

Run go build -o hello main.go. The output is roughly 1.8 megabytes on modern systems. That number sounds high for a single print statement. It is not your code taking up space. It is the Go runtime and the standard library. The compiler embeds the garbage collector, the goroutine scheduler, and the entire fmt package directly into the executable. You get a file that runs on any Linux machine without needing shared libraries installed.

Add the linker flags to remove debug metadata.

# -s removes the symbol table, -w removes DWARF debug info
go build -ldflags="-s -w" -o hello-stripped main.go

The new binary drops to roughly 1.2 megabytes. The program runs identically. The difference is purely in the metadata attached to the executable. The machine code that actually executes your logic has not changed. Only the diagnostic information has been removed.

What the linker actually does

The Go toolchain runs in two distinct phases. The compiler translates your source code into object files. Each object file contains machine code, relocation information, and references to external functions. The linker takes all those object files and merges them into a single executable. During this merge, the linker builds the symbol table and attaches the DWARF sections. The -s flag tells the linker to skip the symbol table generation. The -w flag tells it to skip the DWARF debug info. Without those sections, the file shrinks immediately. The strip command from GNU binutils can do the same job on an already built binary, but passing the flags directly to go build is cleaner because it avoids a second pass over the file.

Static linking is the reason Go binaries are self-contained. The linker resolves every function call at build time. It does not leave placeholders for dynamic libraries like libc.so. That choice eliminates dependency hell. It also means you cannot shrink the binary by removing unused standard library packages. The linker includes everything that is transitively referenced. If your code imports net/http, the entire HTTP stack gets packed in. You reduce size by removing debug metadata, not by trimming the standard library.

A production-ready build script

Production builds usually combine multiple techniques. You want to remove debug info, compress the result, and verify the output. Here is a typical build script for a service that ships to constrained environments.

#!/usr/bin/env bash
# Build script removes debug metadata and compresses the final binary
set -euo pipefail

# -s strips symbols, -w strips DWARF, -X sets version variables at link time
LDFLAGS="-s -w -X main.Version=1.0.0"

# Compile with stripped metadata and output to a temporary name
go build -ldflags="$LDFLAGS" -o myapp.tmp main.go

# UPX compresses the executable to save storage and bandwidth
upx --best --lzma myapp.tmp -o myapp

# Verify the binary still runs correctly after compression
./myapp --version

The -X flag is a Go linker convention. It lets you inject string values directly into the binary at link time, which is how most projects embed version numbers without recompiling. UPX uses LZMA compression to pack the executable into a smaller archive. When the OS loads the binary, UPX decompresses it into memory on the fly. The trade-off is a tiny delay at startup and slightly higher CPU usage during that decompression step.

Go builds follow a simple naming convention. The output binary takes the name of the directory or the -o flag. If you build a module named github.com/user/project, the default output is project. The community expects go build to produce a ready-to-run executable without extra configuration. You do not need a manifest file to manage the build step. The toolchain handles it. Trust the default behavior until you have a measured reason to change it.

What breaks when you strip too much

Stripping and compression are not free. Removing the symbol table and DWARF info breaks panic traces. When a program crashes, the runtime prints a stack trace with function names and file paths. Without that metadata, the trace shows raw memory addresses instead of readable names. You lose the ability to debug production crashes using standard tools. Profiling also suffers. CPU and memory profilers rely on symbol information to map samples back to your code. A stripped binary produces profiles with unnamed functions, which makes optimization nearly impossible.

UPX introduces compatibility issues on some platforms. Certain container runtimes and security scanners flag UPX-compressed binaries as suspicious because malware authors use the same technique to hide payloads. Some older Linux kernels or minimal Alpine images lack the necessary decompression support, causing the loader to fail with an exec format error or a cannot execute binary file message from the shell. If your deployment target runs on ARM-based embedded hardware, UPX support is limited and often unstable.

The compiler will not stop you from stripping your binary. It trusts your flags. If you accidentally pass -s during a debug build, you will spend hours wondering why your debugger shows only assembly code. The linker does not warn you. It just obeys the instruction. If you try to run a UPX-compressed binary on a system that blocks compressed executables, the kernel returns a permission denied or bad ELF interpreter error. The failure happens at load time, not compile time. You only discover it when the deployment pipeline tries to start the container.

Binary size is a trade-off between convenience and constraints. Strip what you do not need. Compress only when you must.

Choosing the right approach

Use default go build when you are developing locally or need full panic traces and profiler support. Use -ldflags="-s -w" when you ship to production and want a smaller binary without sacrificing execution speed. Use strip when you cannot modify the build command but still need to remove leftover symbols from a third-party binary. Use UPX when storage or bandwidth is strictly limited and your target platform supports compressed executables. Skip compression entirely when you deploy to modern cloud infrastructure where storage costs are negligible and startup latency matters more.

Where to go next