Why is a compiled Go hello-world binary a couple of megabytes rather than a few kilobytes?
answer
- nothing is loaded at run time
- the collector ships inside your program
- names and line numbers for every function
- type names must survive to run time
- a high floor, then gentle growth
basics
~20 sGo links statically and ships the whole runtime — collector, goroutine scheduler, allocator — plus per-type metadata, the function and line tables behind stack traces, and debug information. That fixed baseline dwarfs a tiny program's own code.
solid answer
~50 sA Go executable is self-contained: there is no shared Go runtime on the target machine, so the garbage collector, the goroutine scheduler, the allocator and the map, channel and panic machinery are linked in even when `main` only prints a line. On top of that the compiler emits a descriptor and a name string for every type that survives to run time, so interface conversions, type assertions, `%T` printing and reflection work; the linker emits the pclntab, the tables mapping instruction addresses to function names and line numbers that make stack traces and panics possible; and `go build` keeps DWARF debug information and a symbol table by default. Importing `fmt` alone pulls in `reflect` and a lot of formatting code. So there is a fixed floor of roughly a couple of megabytes, and above that size grows with your own code and types.
go deeper
Be ready to say that a Go binary is self-contained: it carries the runtime, type information, stack-trace tables and debug data, so there is a fixed floor of a couple of megabytes before your own code counts.
Explain the pieces separately — runtime, function and line tables, per-type metadata, debug information — and say which of them grow with your code and which stay roughly constant.
Show that you have measured rather than guessed: name what you would inspect in a real artifact and what a size number does and does not tell you about a deployment.
Own the tradeoff between a large self-contained artifact and the deployment simplicity it buys, and be clear about when artifact size is a real constraint rather than an aesthetic complaint.
## The question behind the question Engineers arriving from C, or from a language with a system-wide runtime, expect a program that prints one line to compile to a few kilobytes. In Go it does not, and the reason is worth understanding because it also explains why Go binaries are easy to deploy: everything the program needs is *inside the file*. ## What is in the file ### 1. The runtime Go's runtime is a substantial piece of software, and it is linked into every binary: the concurrent garbage collector, the goroutine scheduler, the memory allocator, the implementations of maps and channels, the `panic`/`recover` machinery, stack growth and copying, and the network poller. None of this lives in a shared library that the operating system provides, so none of it can be omitted. A program that never starts a goroutine still carries the scheduler, because the runtime itself uses goroutines. ### 2. Function and line tables (the pclntab) When a Go program panics it prints a stack trace with function names, file names and line numbers, and it does that in a release build with no special flags. That is only possible because the linker emits a table that maps every instruction address back to its function, its source file and its line. The same tables are used to unwind stacks during panics, when the garbage collector scans a goroutine's stack, and when you take a profile. This structure is one of the largest single contributors to binary size, and unlike the runtime it grows in proportion to how much code you have. ### 3. Type metadata For every type that can be seen at run time the compiler emits a descriptor: its size, its alignment, its kind, its name as a string, a helper for equality, and a bitmap saying which words of a value hold pointers. Interface values point at these descriptors, `%T` prints from them, a type assertion compares them, and reflection reads them. Types that are converted to interfaces also cause the compiler to emit a method table for that (type, interface) pair. All of this is data in the binary. ### 4. Debug information By default `go build` keeps DWARF debug sections and a symbol table, so a debugger can attach and a profiler can attribute samples to functions. This is a meaningful fraction of the file, and it is why an unmodified Go build is friendlier to diagnose than a stripped C binary. ### 5. Static linking A pure-Go build on Linux makes system calls directly and needs no C library on the target, which is exactly why a Go binary can be dropped into an empty container or onto an appliance. When cgo is involved — for example some builds of DNS resolution or user lookup — the binary may instead link dynamically against the system C library, which trades the self-containment away. ## The linker does remove code It is a misconception that Go ships everything you imported. The linker performs reachability-based dead-code elimination: functions that nothing can reach are dropped, along with their metadata. What you cannot drop is the part of the runtime the program genuinely needs, and that part is large and roughly constant. This is why the curve looks like a high floor followed by gentle growth: a hello world and a ten-thousand-line service differ far less than a naive reading of their source sizes would suggest. ## Why `fmt` costs more than it looks `fmt.Println` takes `...any`, so its arguments are boxed into interface values, and formatting them means inspecting their dynamic types. `fmt` depends on `reflect`, and `reflect` is both a lot of code and a reason for the linker to be conservative about what type and method metadata it can discard. Swapping `fmt.Println` for a direct write to standard output measurably shrinks a trivial program. ## Seeing it yourself Build the program and inspect it: `go tool nm -size -sort size ./hello | head -30` lists the largest symbols, and `go version -m ./hello` prints the toolchain version and build settings baked into the file. Both are worth running once, because after that the number stops being mysterious: you can point at the runtime, the tables and the type metadata and say where the megabytes went.
- As a Go program gets larger, which part of the binary grows fastest?The parts proportional to your code: the function and line tables the runtime uses for stack traces, the machine code itself, the debug information, and the per-type metadata for the types you declare. The runtime is close to a constant.
- Why does a pure-Go binary run on a machine with no Go installed and no matching C library?Because it is statically linked and makes system calls itself; the runtime it needs is inside the executable. That changes when cgo is enabled, since the binary may then depend on the system C library at load time.
- Does the Go linker remove code you never call?Yes. It walks reachability from the entry point and drops unreachable functions and their metadata. What it cannot drop is the runtime the program actually uses, which is why the baseline stays high even for a trivial program.
It is less like shipping a document and more like shipping the reader along with it: the binary carries its own runtime instead of expecting one on the machine.
saying these in an interview costs you the question
- Says Go binaries embed an interpreter or virtual machine
- Claims the linker never removes unused code
- Thinks binary size scales linearly with lines of source
- Blames the garbage collector for all of the size
- Assumes the target machine must have Go installed