skip to content

Type Metadata in Binaries

Every type used dynamically carries a runtime descriptor holding its kind, size and pointer bitmap, which is what makes interfaces and reflection work and what makes Go binaries large.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

5

Why is a compiled Go hello-world binary a couple of megabytes rather than a few kilobytes?

level: juniorimportance: should knowfreq 42%

answer

  1. nothing is loaded at run time
  2. the collector ships inside your program
  3. names and line numbers for every function
  4. type names must survive to run time
  5. a high floor, then gentle growth

basics

~20 s

Go 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 s

A 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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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
open as a page

What does Go's per-type runtime descriptor contain, and what reads it at run time?

level: middleimportance: should knowfreq 34%

basics

~20 s

Each type gets one shared descriptor in read-only data holding its size, alignment, kind, name and an equality helper, plus a bitmap marking which words of a value hold pointers. The collector reads the bitmap; interfaces, assertions, printing and reflection read the rest.

open as a page

A Go agent binary for edge devices grew several megabytes in one release — how do you find why?

level: seniorimportance: should knowfreq 28%

basics

~20 s

Rebuild both revisions with the same toolchain and flags, compare file sizes, then list each binary's symbols with go tool nm sorted by size and diff the two lists. A jump in type metadata, method tables and reflection code points at name-based dispatch defeating the linker's pruning.

open as a page

How does Go's linker decide which methods and itabs it can drop from the final binary?

level: middleimportance: nice to knowfreq 22%

basics

~20 s

The linker keeps what it can reach. A method survives if it is called directly, or if its name and signature match a method of an interface that reachable code uses and its type is converted to an interface. Reflective lookup by method name breaks that reasoning and forces it to keep far more.

open as a page

Name-based reflective dispatch in a Go agent shipped to edge devices — do you allow it, and who decides?

level: principalimportance: nice to knowfreq 16%

basics

~20 s

Treat it as a policy owned by whoever owns the shipped artifact, not as a per-review taste question. Require a measurement before it lands, prefer explicit registration tables that keep the linker pruning, and enforce an artifact-size ceiling in the build so the cost is visible rather than argued about.

open as a page