The Go Pipeline
Go's compiler is fast because the language was designed to let it be: no headers, no textual inclusion, a strictly acyclic import graph, a compact export summary per package, and a deliberately small feature set. It has its own backend, and it emits one self-contained binary.
Why does Go compile so fast, and what did the language give up to make that true?
Per package, in one process: source files, a syntax tree, a type-checked tree, the compiler's own SSA-form IR over a control-flow graph, then machine code. Two artifacts come out — an object file and an *export data* section: a compact binary summary of everything the package exports, including the bodies of functions small enough to be inlined across the boundary. That summary is the entire interface a dependent package reads. There is no header, no textual inclusion and no reparse: a package that imports twenty others reads twenty summaries, and the question the representation exists to answer is "what do I need to know about this package", not "what does this package contain".
The compiler is entitled to assume the import graph is a directed acyclic graph, because the language makes an import cycle a compile error rather than something to resolve. That single rule is what makes the build a topological order in which every package is compiled exactly once, from summaries of things already compiled, with no fixed-point iteration and no repeated work. It may also assume it is compiling the whole package at once — the package, not the file, is the unit — so it can see every declaration without any forward declaration. What it may not assume is anything about a package it has not been given the export data for; the build system, not the compiler, is responsible for supplying them in dependency order.
Key points
- Go compiles fast because the language removed the expensive work: no preprocessor, no headers, no import cycles, minimal inference, and a small feature set.
- The package is the compilation unit, and a dependent reads a compact export summary instead of reparsing any source.
- A strictly acyclic import graph makes the build a topological order in which each package is compiled exactly once.
- The
gctoolchain has its own SSA backend rather than using LLVM, trading peak optimization for compile speed and tight coupling with its runtime. - The compiler owes the runtime real artifacts: stack maps for the collector, write barriers, stack-growth prologues and preemption support.
- Generics are implemented by GC-shape stenciling plus dictionaries — one instance per memory layout, with per-type information passed in — which is neither monomorphization nor erasure.
- Output is one self-contained binary, which costs a size floor and gives up shared-library patching;
cgoopts back out of it.
The route, and how short it is
gc toolchain — the compiler in cmd/compile shipped with the Go distribution — for roughly Go 1.21–1.24. It is not the only implementation: gccgo is a GCC frontend using GENERIC/GIMPLE/RTL and GCC's optimizers, and TinyGo compiles a subset through LLVM for microcontrollers and WebAssembly. Both produce Go programs and neither shares this stage list, so every performance and build-time statement here names one toolchain.Go's pipeline has fewer distinct representations than any other statically typed language in this module, and that is the point rather than an omission. Parse, type-check, build SSA, optimize modestly, emit machine code, link. There is no separate high-level IR to desugar into, no trait resolution to run to a fixed point, no monomorphization pass multiplying the work, and no LLVM.
The unit is the package. All the files in a package are parsed together, so order does not matter and nothing needs declaring before use, and the compiler emits one object file plus the export summary. Dependents read the summary — a few kilobytes describing exported types, function signatures, constants and the bodies of inlinable functions — instead of reparsing anything.
Compare that with [[cpp-pipeline]], where a two-line file that includes one standard header becomes a thirty-eight-thousand-line translation unit, and every other file that includes the same header produces its own copy. Go pays that cost once per package, ever, rather than once per including file. The saving is not a clever implementation; it is a different model, and the language was shaped around it.
gc toolchain, from .go files to a running processimplementation- Source filesyou write itAll
.gofiles of one package, compiled together as a unit. - Syntax treebuild timeOne tree per package, built by a hand-written recursive-descent parser.Structure, with no preprocessor stage anywhere before it — the language has no macros to expand.Comments, except the directive comments the toolchain reads deliberately.
- Type-checked treebuild timeThe tree with types resolved, using the export data of imported packages.Types, and interface satisfaction — which is structural, so it is decided here rather than declared anywhere — see
[[structural-vs-nominal]]. - Generic instantiationbuild timeInstantiated bodies, one per GC shape rather than one per type argument.Concrete code for generic functions, sharing one instance among types with the same memory layout and passing a dictionary for the rest.
- SSA IRbuild timeA control-flow graph in static single assignment form, in the compiler's own IR.A form to optimize on —
[[static-single-assignment]]— plus the compiler's obligations to the runtime, written out explicitly.Source structure. This is also where bounds checks and nil checks become real branches. - Machine codebuild timeTarget instructions from the toolchain's own backend, with stack maps and a function table.The metadata the runtime needs: which stack slots hold pointers at each safepoint, and the tables that make stack growth and preemption possible.Portability, and most of the source-level story except what DWARF preserves.
- Linkbuild timeOne executable containing the program, the runtime, the garbage collector and all type metadata.Self-containment. There is nothing to install on the target machine.The ability to update a dependency without rebuilding — everything is baked in.
- Executionrun timeA process whose first code is the Go runtime, which then starts the goroutine running
main.Scheduling, the collector, and the growth of every goroutine stack the compiler emitted prologue checks for.
Read it asRead the adds column at the machine-code stage. The compiler is not only generating instructions; it is generating the tables its own runtime needs to find pointers, grow stacks and preempt goroutines. That obligation is why the toolchain owns its backend, and it is the compiler-side half of a runtime this domain deliberately does not otherwise cover.
Fast compilation is a language decision, not a compiler trick
The compiler is fast because it was never asked to do the expensive things. There is no preprocessor, so nothing is re-expanded per file. There are no headers, so a dependent reads a summary instead of source. Import cycles are an error, so the build is a topological sort over a directed acyclic graph of packages, and each is compiled exactly once from summaries that are already finished. The type system has no inference beyond the local := form, no implicit numeric conversions and no user-defined operator overloading, so type checking is close to a single pass.
And the feature set is small on purpose. There is no metaprogramming facility that runs code at compile time, no template language, no macro system, and until 1.18 no generics at all. Every one of those is a thing the compiler does not have to do, and the absence of each is also a real expressive loss that Go accepts explicitly.
The counterpart is code generation. Because the language cannot express certain abstractions, the ecosystem generates source instead — go generate, stringer, mocking tools, protobuf compilers. That work still happens, but it happens in a separate, cacheable step that produces ordinary Go the compiler checks normally, with ordinary error messages. It is a deliberate relocation of metaprogramming out of the compiler and into the build.
| Language | The interface artifact | Cost per dependent | What breaks the model |
|---|---|---|---|
| C++ (classic) | A header, included textually | Reparse the header, and everything it includes, in every unit | Nothing — the reparse is the model |
| C++ (modules) | A compiled module interface | Read a binary interface once | Adoption: every build system and every dependency must move together |
| Go | Export data emitted beside the object file | Read a compact summary once per importing package | Nothing in-language; cgo steps outside it and slows everything down |
| Rust | Crate metadata plus MIR for generic code | Read metadata, then monomorphize the generics locally | Generic-heavy dependencies push their codegen cost onto you |
Its own backend, and the runtime it has to serve
gc-specific and version-specific: register-based argument passing arrived in 1.17, profile-guided optimization became available in 1.21, and inlining budgets and escape-analysis precision change most releases. gccgo and TinyGo generate different code entirely for the same source, so a benchmark comparing "Go" to another language is comparing one toolchain at one version.The gc toolchain does not use LLVM. It has its own SSA-based backend, with its own optimization passes and its own instruction selection for each supported architecture. The usual reaction is that this must be a mistake — LLVM optimizes better — and it is worth being precise about what is actually being traded.
What is given up is peak optimization on the code shapes LLVM has spent two decades tuning: aggressive loop transformations, a mature auto-vectorizer, and the long tail of [[optimization-levels]] work. On numeric kernels the gap is real and measurable, which is exactly why TinyGo, which does use LLVM, exists.
What is bought is compile speed, control and coupling. Compile speed, because LLVM's optimization pipeline is the single largest term in build time for the languages that use it — Rust is the case study, see [[rust-pipeline]]. Control, because the toolchain owns the calling convention and could change it (Go moved from stack-based to register-based argument passing in 1.17 without asking anyone). And coupling, because the compiler must emit exactly what the runtime consumes: precise stack maps at every safepoint so the collector can find pointers, write barriers around pointer stores, a prologue in almost every function that checks whether the goroutine stack needs to grow, and asynchronous preemption support. Those are compiler outputs designed against one runtime, and they are far easier to maintain in a backend you own.
Generics: one instance per GC shape, plus a dictionary
cmd/compile implemented generics from 1.18 onward, and the Go team has been explicit that it is an implementation strategy that may change — full monomorphization for some instantiations, or more aggressive devirtualization through dictionaries, have both been discussed. Nothing in the language specification requires it, gccgo and TinyGo may differ, and any performance claim about Go generics needs a version number attached.Generics arrived in Go 1.18, and the implementation is neither of the two answers the rest of this module describes. It is not full monomorphization, which would have multiplied compile time and binary size in a toolchain whose whole identity is not doing that. It is not erasure either, since Go has no boxing-everything representation to erase into.
The gc compiler uses *GC-shape stenciling*. Two type arguments share one generated instance if they have the same "GC shape" — the same size, alignment and pointer layout, which is what the garbage collector actually needs to know. All pointer types share a single shape, so []*Foo and []*Bar get one instance between them. Distinct non-pointer layouts get their own: int64 and float64 do not share, because the operations differ even though the size does not.
The parts that genuinely differ per type — which concrete method to call, which type descriptor to use — are passed in a *dictionary*, an extra hidden argument holding that per-instantiation information. So a method call on a type parameter becomes a dictionary lookup and an indirect call rather than a direct one, which is the cost. It is a middle position: much less code than monomorphization, some indirection that monomorphization would not have, and a devirtualization and inlining story that is weaker than either C++ templates or Rust generics on the same code.
1func Map[T, U any](xs []T, f func(T) U) []U {2 out := make([]U, 0, len(xs))3 for _, x := range xs {4 out = append(out, f(x))5 }6 return out7}8 9// Map[*User, *Row] and Map[*Order, *Row] -> one shared instance10// both instantiations are pointer-shaped in T and U11// Map[int64, string] and Map[float64, string] -> two instances12// int64 and float64 have different shapes despite the same sizeThe sharing is decided by memory layout, not by the type system. That is why the rule reads oddly from a language point of view and perfectly from a garbage collector's: the collector needs to know where the pointers are, and two types with pointers in the same places are interchangeable to it.
One file, and everything is in it
A Go build produces a single executable containing the program, the runtime, the garbage collector, the scheduler, and type metadata for reflection. Nothing needs to be installed on the target machine — no interpreter, no shared library, no version-matched runtime. Copy the file and run it, which is why the language spread through container images and command-line tools so quickly: the image can be a scratch base plus one file.
The price is a floor on binary size — a trivial program is a couple of megabytes because the runtime is in it — and the loss of shared-library updates: a fix in a dependency means rebuilding and redeploying every binary that used it, rather than patching one .so. That is a genuine operational trade, and it is the same one [[static-linking]] and [[dynamic-linking]] describe in general.
The exception is cgo. Calling C requires a C toolchain at build time, brings in the platform's C library, and by default produces a dynamically linked binary — so a Go program that uses cgo gives up exactly the property people chose Go for. CGO_ENABLED=0 is the flag that turns it off, and discovering that a pure-Go program suddenly needs libc at runtime is almost always a transitive dependency pulling cgo in.
| Component | What it is for | Can it be removed? |
|---|---|---|
| The runtime | Scheduler, memory allocator, channel and goroutine machinery | No — it is the execution model |
| The garbage collector | Concurrent mark-and-sweep, using the stack maps the compiler emitted | No |
| Type metadata | Runtime type descriptors, used by reflection and by interface dispatch | Partly — the linker prunes what provably nothing reflects on |
pclntab | Program-counter-to-line tables, for panics, tracebacks and the profiler | Not without losing readable stack traces |
| DWARF debug info | Debugger support | Yes — -ldflags="-w -s" strips it and shrinks the binary noticeably |
How it works
The steps, in the order the compiler takes them.
- The build tool computes the package dependency graph, rejects cycles, and compiles packages in topological order, in parallel where the graph allows.
- For each package, all its files are parsed together into one syntax tree by a hand-written recursive-descent parser.
- Type checking runs against the export data of already-compiled imports, resolving interface satisfaction structurally rather than by declaration.
- Generic functions are instantiated per GC shape; instantiations sharing a shape share generated code and receive a dictionary argument carrying the per-type information.
- The tree is lowered into the compiler's SSA IR, where optimization passes run and where bounds checks, nil checks, write barriers and stack-growth prologues become explicit.
- The backend selects instructions and allocates registers for the target, emitting machine code plus stack maps identifying pointer slots at every safepoint.
- The package's exported interface — including bodies of inlinable functions — is written as export data beside the object file, for dependents to read.
- The linker combines every package object with the runtime into one executable, pruning unreachable code and type metadata as it goes.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- An import cycle that is obvious in a diagram is a hard compile error, forcing a refactor — usually extracting an interface — that a language permitting cycles would not have demanded.
- A binary is four megabytes for a hundred lines of code, and the size is the runtime, the collector and the type metadata rather than anything anyone wrote.
- A build that used to produce a static binary starts requiring
libcat run time, because a transitive dependency enabledcgoand nothing in the local source mentions it. - A generic function is measurably slower than the equivalent concrete one, because calls through the dictionary are indirect and did not inline.
- A numeric loop is significantly slower than the C or Rust equivalent, because the backend has no comparable auto-vectorizer, and no flag changes it.
- A deployment needs a security fix in a dependency and every binary in the fleet must be rebuilt and redeployed, because there is no shared library to patch.
When it helps
- Reasoning about build times in a large codebase, where Go's model makes the total roughly linear in code rather than in the include graph.
- Deployment and packaging decisions, since a single self-contained binary changes what a container image and a release process have to contain.
- Understanding the ceiling on Go performance for compute-heavy code, and knowing when the honest answer is to call out to something else.
- Judging when Go's generics will pay: dictionary indirection means the gain over an interface is smaller than the gain monomorphization gives in Rust or C++.
When it hurts
- Compute kernels where vectorization decides the result. The gap against an LLVM- or GCC-based toolchain is real and is not closed by writing the loop differently.
- Carrying "Go compiles fast" to a build that uses
cgoheavily, which reintroduces a C toolchain and most of its costs. - Expecting generics to behave like C++ templates or Rust generics on performance. The implementation is a different point on the spectrum and behaves like one.
What it costs
Every one of these is paid by something.
- Forbidding import cycles buys a build that is a single topological pass with no fixed-point iteration, and pays with refactors the language forces on you for a design a cyclic language would have permitted.
- Export data instead of headers buys compile time linear in packages rather than in include edges, and pays with a binary interface format the toolchain must version and keep compatible.
- A small feature set buys a fast, simple compiler and short error messages, and pays in expressiveness — no compile-time computation, no macros, and code generation pushed out into the build.
- Owning the backend buys compile speed and the freedom to change the calling convention and the runtime contract together, and pays with an optimizer that is behind LLVM and GCC on the hardest code and will remain so.
- GC-shape stenciling buys much smaller output and faster builds than monomorphization, and pays with an indirect call through a dictionary where a monomorphized language would have inlined a direct one.
- Static binaries buy trivial deployment and pay with a size floor, with rebuild-everything security patching, and with the loss of the property the moment
cgoappears.
What else you could do
What a different compiler or language does instead, and when that is better.
gccgois a GCC frontend for the same language, reaching GENERIC, GIMPLE and RTL and getting GCC's optimizers — better on some numeric code, much slower to compile. See[[gcc]].- TinyGo compiles a subset through LLVM for microcontrollers and WebAssembly, trading full runtime support and fast builds for small binaries and LLVM code quality — see
[[webassembly]]. - Full monomorphization, as in
[[rust-pipeline]]and[[templates]], produces better generic code and costs the compile time and binary size Go's implementation exists to avoid. - Erasure with boxing, as in Java generics, shares one implementation and pays with allocation and indirection everywhere — see
[[type-erasure]]. - Dynamic linking gives back shared-library patching and smaller images at the cost of the self-contained deployment story — see
[[dynamic-linking]].
See it for yourself
The flag, dump or tool that shows you this directly.
go build -xprints every command the build runs;go build -a -xforces a full rebuild so you can see the whole graph being walked.go tool compile -S file.goprints the assembly with the compiler's own annotations, including bounds checks and write barriers.go build -gcflags=-mreports inlining and escape-analysis decisions per line — the fastest way to see why something allocated.go build -gcflags=-d=ssa/check/onandGOSSAFUNC=Name go buildwrite an HTML dump of every SSA pass for one function.go tool nm,go tool link -h, and-ldflags="-w -s"for what is in the binary and how much of it is debug info;go version -m ./binaryprints the module versions baked in.CGO_ENABLED=0 go buildproves whether a program is really pure Go, andfile ./binaryorldd ./binarysays whether the result is static.
Plausible wrong readings
Stated the way a confident engineer states them.
- "Go is fast to compile because the compiler is well written." It is well written, and that is not the reason. The reason is a set of language decisions — no headers, no cycles, no metaprogramming — each of which removed work the compiler would otherwise have to do.
- "Go does not use LLVM because it predates it." Go's toolchain postdates LLVM by years. The choice is about compile speed and about owning the contract between compiler and runtime.
- "Go generics are monomorphized like Rust's." They share one instance per GC shape and pass a dictionary. That is a third answer, and it performs like a third answer.
- "A Go binary is static, so it has no dependencies." True until
cgoenters the graph, at which point the binary is dynamically linked against the system C library and nothing in your source says so. - "The runtime is a library the compiler links in." It is that, and it also depends on tables the compiler must emit — stack maps, safepoints, function metadata. The two are designed together.
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
Go compiles a whole package at once and writes down a small summary of what that package offers. Anything that imports it reads the summary instead of the source, and because packages are not allowed to import each other in a circle, the compiler can go through them in order, once each. That, plus a language with no macros and few features, is why builds are fast. The output is one file with the program and its runtime inside it.
practical
go build -gcflags=-m is the tool worth knowing: it tells you what inlined and what escaped to the heap, which is most of what you can act on. Use CGO_ENABLED=0 when you actually need a static binary, and check with file rather than assuming. If a generic function is hot, benchmark it against a concrete version — the dictionary indirection is real, and the answer is not the one Rust or C++ intuition predicts. And strip debug info with -ldflags="-w -s" only when you are willing to lose readable debugger sessions; the panic tracebacks survive either way.
advanced
The instructive thing about Go is that it is the clearest case in the domain of a language designed backward from a compiler property. Fast builds were a stated goal, and almost every notable omission — no macros, no compile-time evaluation, no inheritance, no exceptions, no import cycles, and for thirteen years no generics — removes work a compiler would otherwise do. The result is genuinely fast and genuinely limited, and both halves are the same decision. It also shows the cost of that coupling: because the compiler owns the calling convention, the stack maps and the write barriers, changing the runtime means changing the backend, and changing the backend to LLVM would mean re-implementing all of it. The toolchain's independence is what lets it move fast on the contract with the runtime, and it is also what keeps its optimizer permanently behind the ones two decades of other people's work went into — see [[llvm]] for the other side of that argument.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
gc toolchain shipped with the Go distribution, roughly 1.21–1.24. gccgo is a GCC frontend with GCC's IRs and optimizers, and TinyGo goes through LLVM for embedded targets and WebAssembly. Compile speed, generated code quality and generics implementation all differ between the three, so a claim about "Go" without a toolchain name is not checkable.gc implementation of generics, introduced in 1.18 and explicitly described by the Go team as changeable. The language specification says nothing about how instantiation is performed, so the performance characteristics of generic code are a version fact rather than a language fact.gc backend optimizes less aggressively than LLVM or GCC is the general pattern rather than a universal result: for allocation-light, branch-heavy service code the gap is often negligible or absent, and profile-guided optimization (available since 1.21) narrows it further on hot paths. The gap is widest on loops that a mature auto-vectorizer would transform.go tool compile -S are specific to the GOARCH they were produced for, and hand-written Go assembly is written against a per-architecture pseudo-assembly that also changes.If you were asked this in an interview
- Why does Go compile faster than C++, and how much of the answer is about the compiler versus the language?
- What does the Go compiler have to emit for its garbage collector, and why does that make owning the backend attractive?
- How are Go generics implemented, and what does that cost at a call site compared with monomorphization?
Connections
- Programming Languages & Runtime Internals — The goroutine scheduler, growable stacks and the concurrent collectorEvery stack map, write barrier and prologue check this lesson describes exists to serve a runtime whose mechanics are owned there. The compiler emits the metadata; what consumes it — and what a garbage collector does with a precise stack map — is the other half.
- DevOps / Production Engineering — Container images, deployment artifacts and patching a fleetThe single static binary is an operational property before it is a compiler one: it decides what a base image contains and what a security fix in a dependency costs to roll out. The build and release practice around that belongs there.