Furkan Kolcu

Software Engineer

Inside Go — Part 1: The Compilation Pipeline

Loading...
Inside Go — Part 1: The Compilation Pipeline
When you run go build main.go, a lot happens behind the scenes before you get an executable binary. Go’s toolchain is famously fast and simple to use, but under the hood, it performs several sophisticated steps. Let’s peel back the curtain and see how the compilation pipeline works.

Think of it like making a cup of coffee:
  • You start with raw beans (your .go files).
  • They’re ground and filtered (lexing and parsing).
  • The flavors are extracted (type checking and SSA).
  • The brew is refined (optimizations).
  • Finally, you pour it into a cup (machine code + binary).

By the end, you’re no longer dealing with beans — you have something ready to consume: a native executable.

From Source to Binary: The Big Picture

Here’s the pipeline in Go:

  1. Lexing and Parsing → turn text into a structured tree.
  2. Type Checking → ensure the structure makes sense.
  3. SSA (Static Single Assignment) → simplify the code for optimization.
  4. Optimization → remove waste, streamline logic.
  5. Code Generation → produce assembly for your CPU.
  6. Linking → package everything into a single binary.

Each step has a clear role — like different workers on a factory line.

Step 1: Lexing and Parsing

Imagine you’re reading a recipe. First, your brain recognizes letters and words (lexing). Then you understand the meaning of the sentences (parsing).

The Go compiler does the same with your .go files:
  • Lexing: Breaks raw text into tokens (keywords, identifiers, literals).
  • Parsing: Arranges tokens into a structured representation according to Go’s grammar.
go
package main

import "fmt"

func main() {
    fmt.Println("Hello, Go")
}
Lexer output (conceptual):
  • package, main
  • import, "fmt"
  • func, main, (, )
  • fmt, ., Println, (, "Hello, Go", )
Parser output: A tree that says “there’s a package named main, with a function named main, which calls fmt.Println with a string.”

How to peek into this stage

Internally, Go uses go tool compile -W main.go to show parser warnings and trees — but running this directly often fails because it doesn’t resolve imports.

Instead, you can use:
bash
go build -gcflags="-W" main.go
This runs the compiler through go build, so dependencies like fmt are resolved correctly.

Step 2: Abstract Syntax Trees (ASTs)

After parsing, the code becomes an Abstract Syntax Tree (AST). Think of this as the “blueprint” of your program.
For our main.go, the AST might look (simplified) like this:

plain
File
 ├── Package: main
 ├── Import: fmt
 └── FuncDecl: main
      └── CallExpr: fmt.Println
            └── Arg: "Hello, Go"
In Go, you can even play with ASTs yourself using the go/ast and go/parser packages. 
Create a helper file, astdump.go:
go
package main

import (
	"fmt"
	"go/ast"
	"go/parser"
	"go/token"
	"os"
)

func main() {
	if len(os.Args) < 2 {
		fmt.Fprintln(os.Stderr, "usage: astdump <file.go>")
		os.Exit(2)
	}
	filename := os.Args[1]

	fset := token.NewFileSet()
	file, err := parser.ParseFile(fset, filename, nil, parser.ParseComments)
	if err != nil {
		panic(err)
	}

	// Pretty-print the AST
	ast.Print(fset, file)
}
Build the tool, then run it:
bash
go build -o astdump astdump.go
./astdump main.go

Step 3: SSA (Static Single Assignment)

This is where things get abstract. SSA means every variable gets assigned exactly once. Think of it like boarding passes on a plane: each passenger (variable) gets a unique seat (assignment). No confusion about who sits where.

go
x := 1
x = x + 2
fmt.Println(x)
  • In “normal” Go code, x is like a passenger who sometimes sits in seat 12A, then moves to 14C, then gets reassigned to 22B. If you ask “where is x right now?” you need context.
  • In SSA, every time x changes, the compiler makes a new passenger with a new boarding pass. x0 has seat 12A, x1 has seat 14C. They never move — you always know exactly where each version is.
In normal code, x changes. In SSA, the compiler rewrites this into something like:
plain
x0 = 1
x1 = x0 + 2
call Println(x1)
Now the compiler can reason about values without guessing which version of x is current.

How to see SSA in Go

You can ask the compiler to dump SSA form for a given function with:
bash
GOSSAFUNC=main go build main.go
This generates an ssa.html file in your working directory. Open it in a browser.

Step 4: Optimizations

When we say optimizations, we mean the compiler rewrites your program’s instructions into a faster or smaller version, without changing what the program does. Think of it like editing a long sentence:

  • Original: “In the event that you want to exit, leave through the door.”
  • Optimized: “If you want to exit, use the door.”
Same meaning, fewer words. Go’s compiler does the same for your code.

Once in SSA, the Go compiler applies several optimizations:

Constant folding:
If an expression uses only constants, the compiler can compute it ahead of time.

go
func main() {
    x := 2 * 3
    println(x)
}
  • You wrote 2 * 3.
  • The compiler changes it to 6.
  • At runtime, it just prints 6 — no multiplication needed.
Dead code elimination
If the compiler sees code that can never run, it deletes it.
go
func main() {
    if false {
        println("This will never run")
    }
    println("Hello")
}
  • The if false branch is gone in the final binary.
  • The program only contains code for println("Hello").
Inlining:
If you call a very small function, the compiler might replace the call with the function body.
go
func add(a, b int) int {
    return a + b
}

func main() {
    println(add(2, 3))
}
The compiler may inline this into:
go
func main() {
    println(2 + 3)
}
This avoids the cost of making a function call (stack setup, jumping, returning).

Escape analysis (stack vs heap):
Go tries to keep variables on the stack (cheap, local) instead of the heap (slower, requires garbage collection).
go
func makeNumber() *int {
    x := 42
    return &x
}
Here x has to live on the heap — because after makeNumber returns, x would disappear if it were on the stack.

But:
go
func addOne(n int) int {
    return n + 1
}
Here n stays on the stack because it never escapes the function. This decision is made during escape analysis, one of the compiler’s optimizations.

Step 5: Code Generation and Linking

After optimization, the compiler emits machine code for your platform. The linker then:

  • Stitches together multiple packages.
  • Resolves imports (like fmt).
  • Produces a final executable binary.
Go differs from many languages here: it ships with a self-contained binary, so you don’t need an external runtime or VM (like Java’s JVM or Python’s interpreter).

How Go Differs from Traditional Compilers

  • Simplicity: One command (go build) handles fetching, compiling, linking.
  • Speed: Designed for fast builds, even with large codebases.
  • Self-contained binaries: No need for an external runtime.
  • Cross-compilation: With a single command, you can build for Linux, Windows, macOS, ARM, etc.

Wrapping Up

So that’s the journey of a .go file:
Text -> Tokens -> AST -> SSA -> Optimized Machine Code -> Binary

Each step trims away ambiguity and brings the program closer to something the CPU can run.

In the next part of this series, we’ll dive deeper into how Go manages memory and what makes it different from other languages.

You also can check other topics in the series using the following card.

Inside Go — How It Really Works: Series Kickoff
Inside Go — How It Really Works: Series Kickoff

Loading...