Go produces a self-contained executable that includes the Go runtime, your code, and required standard-library parts. On Linux with cgo disabled the result is fully static. With cgo enabled or on platforms like macOS, the binary links to system libraries. This makes delivery easy but it also leads to larger binaries than some expect. The toolchain always prunes unreachable code while symbol and DWARF data stay in by default unless you pass -ldflags “-s -w”. Compiler and linker flags give more control over how much of the executable is kept or removed.
Mechanics of Symbol Stripping and Code Pruning
Go binaries don’t keep every piece of information that goes into a build. The compiler and linker are always asking what needs to stay and what can be discarded. Stripping symbols reduces extra data used for debugging and stack traces, while dead code elimination trims unused functions and imports. Reflection then complicates things by forcing the toolchain to keep more information intact.
How Go Handles Symbols
Symbols are names tied to functions, variables, and methods that the runtime or external tools may need. They provide the map that allows debuggers to resolve memory addresses back to function names or variable references. If you’ve ever seen a stack trace with function names instead of raw addresses, that’s made possible by the symbol table. The Go toolchain decides whether this symbol information remains inside the final binary. By default, it’s included so debugging tools and stack traces work smoothly. Panic stack traces still show function names and file:line while -s drops the OS symbol table and -w drops DWARF, but Go’s own tables used for tracebacks remain.
Sometimes symbol stripping alone can shrink a binary by several megabytes. The difference is more pronounced in projects that rely on many packages or runtime features. It’s worth noting that stripping doesn’t remove logic or code, only metadata tied to debugging and symbol resolution.
Dead Code Elimination
Unused code doesn’t make it into the final binary. The Go compiler marks functions, variables, and even entire packages as reachable or unreachable. If nothing in the application references a piece of code, it’s discarded during linking. This is called dead code elimination, and it works quietly without any input from the developer.
Consider a file that imports the math package but never calls anything from it. The compiler recognizes that no functions from math are reachable and tells the linker not to include them.
That code won’t even compile unless math is used, because Go enforces import usage at build time. If it did, the unused math symbols would be stripped away and the final binary wouldn’t carry them.
A more realistic case is partial use of a package. Suppose a developer calls strings.ToUpper but doesn’t touch other parts of the strings package. Only the code required for ToUpper and its dependencies is retained.
Although strings has dozens of functions, the linker discards the unused ones. The pruning happens automatically and applies across all dependencies. This aggressive trimming is one of the reasons Go binaries are smaller than many fully static executables in other languages.
Reflection and Retained Code
Reflection complicates pruning because the compiler can’t predict what the application will ask for at runtime. Reflection lets a program inspect types, values, and struct tags dynamically. That means some metadata has to be kept even if it looks unused. A classic example is JSON encoding with struct tags. The compiler can’t discard type information for a struct that gets passed to json.Marshal, because the encoder reads the tags and field names at runtime.
That simple example forces the toolchain to keep more information about User than it otherwise would. The struct’s field names and tags must be preserved for json.Marshal to work, even though they aren’t directly referenced in code.
Reflection also affects functions tied to interfaces. If a method is only called through reflection, the compiler can’t mark it as unused, so it survives pruning. This explains why binaries that lean heavily on serialization, dynamic routing, or reflection-driven frameworks tend to be larger.
Consider another case with reflect.TypeOf:
The reflection call forces metadata about Report’s fields to remain inside the binary. Without reflection, most of that information could be stripped. This shows how runtime inspection shapes the size of the executable.
Compiler and Linker Flags That Affect Size
Go binaries are shaped not only by automatic pruning but also by the build flags that give developers direct control over what goes into the final file. The compiler makes the first cut, but the linker carries most of the responsibility for applying flags that adjust size. Each option influences different aspects of the build process, from the format of the binary to the presence of debug information.
Build Mode Impact
Go supports multiple build modes, and each mode changes the composition of the final output. A standard go build produces a self-contained executable that bundles the Go runtime and library code. On some systems it still relies on system libraries, which keeps distribution simple but can add to file size.
-buildmode=c-shared produces a C-ABI shared library from a main package and still carries the Go runtime plus needed packages. It exists to embed Go in a C host, not as a general size-reduction path for Go apps.
That command produces a .so library file that includes the Go runtime and the Go packages it depends on. The host program doesn’t supply a Go runtime. Loading multiple Go c-shared libraries in one process is restricted and can fail because each shared object assumes it is the only Go runtime in the process.
A plugin built with -buildmode=plugin relies on a Go host to load and run it. Size varies by what the plugin contains, and plugins are supported on Linux, FreeBSD, and macOS.
Plugin binaries still carry Go’s runtime and imported packages, so file size stays close to other Go outputs, and they only work in contexts that support plugin loading. The choice of build mode is one of the biggest factors in how much code gets pulled into the binary.
The Trimpath Option
Go embeds build paths into binaries unless told not to. These paths point back to source files on the developer’s machine. While they don’t add a huge amount of weight, they can clutter a binary with machine-specific details that aren’t useful outside of debugging.
The -trimpath flag removes this metadata.
The resulting file no longer contains the absolute source paths. This not only shaves off a little size but also helps reproducibility. Two builds from different directories will no longer differ in embedded paths, which is useful when generating checksums for distribution. Though the reduction isn’t dramatic, it’s part of the broader set of small adjustments that accumulate into measurable savings.
Linker Options with Ldflags
The linker accepts several options through the -ldflags parameter, and these have a more direct impact on file size than build modes. The most common are -s and -w, which strip the symbol table and DWARF data.
That binary runs just like the unstripped version but lacks debugging metadata. Developers often combine these flags with other linker variables. The -X option allows setting values for string variables at build time.
The above embeds a version string into the binary while still stripping debug data. The tradeoff is that some extra bytes are added for the version, though usually the reduction from stripping outweighs the addition.
It’s also possible to embed build time information, commit hashes, or other markers with -X. While this increases size slightly, the impact is minor compared to the removal of debugging data. The linker applies these settings during the final stage, which makes them powerful tools for controlling size and content.
External Linkmode
Go defaults to internal linking, where the Go linker alone builds the final binary. With pure Go (no cgo) on Linux, that binary does not depend on external libc. With cgo or on platforms like macOS or Windows, the final file still links to system libraries and carries a dynamic header by default. Internal linking keeps control in the Go linker, while -linkmode=external hands the final step to the system linker.
With -linkmode=external, the Go toolchain defers linking to the system’s native linker. This shifts some responsibility to the operating system and its libraries.
That command produces a binary that may rely on system libraries instead of embedding them. In some cases this reduces size, especially on systems where libraries are already present. However, portability is reduced because the binary now expects certain libraries to exist on the target machine.
A more specialized case is cgo integration. When Go code calls into C, external linking is sometimes required. This lets the system linker resolve references to C libraries without packaging them entirely inside the binary. The tradeoff is a leaner file at the cost of higher dependency on the environment where the binary runs.
Conclusion
Go’s toolchain works through a careful balance of pruning, stripping, and flag-driven control to decide what belongs in the final binary. Each step, from dead code elimination to linker decisions, has a mechanical reason behind it that shapes the size of the file. With an awareness of how symbols, reflection, build modes, and linker options interact, developers can read binary size not as an accident but as the product of deliberate steps in the build process.











