The idea: translate first, or carry out as you read
A computer only runs machine code. A program written in a high-level language has to get there in one of two ways. A compiler translates the whole program into machine code before it runs, and saves the result as a file (a.out). An interpreter is itself a program, already compiled, that reads your program and carries it out while it runs, one piece at a time. The page runs the same four-line program both ways, on one clock.
total = 0
for i in range(1, N + 1):
total = total + i
print(total)
Both sides read the same source and build the same kind of syntax tree. After that they part ways. The compiler checks every name, then generates seven x86-64 instructions with total in register eax and i in ecx. The interpreter keeps the tree and walks it: for each node it fetches the node, dispatches on its type (Assign, For, Call…) and evaluates it, reading and writing names in an environment table.
Where the time goes
The page counts work in ticks. The compiler pays up front: lex 2, parse 3, check 3, codegen 12, a 20-tick build before anything runs. The program it made then costs 1 tick to set up, 1 tick per loop iteration (four instructions: add, inc, cmp, jl) and 1 tick to print. The interpreter starts after a 3-tick parse, but every statement costs 3 ticks (fetch, dispatch, evaluate), and every iteration costs 5: the For node tests and advances i (2) and the body runs (3).
| N | compiled: build + run | interpreted: parse + run | prints first |
|---|---|---|---|
| 3 (Demo 1) | 20 + 5 = 25 | 3 + 21 = 24 | interpreter |
| 4 (Demo 5) | 20 + 6 = 26 | 3 + 26 = 29 | compiled |
| 1000 (Demo 2) | 20 + 1002 = 1022 | 3 + 5006 = 5009 | compiled, about 5× sooner |
So the compiled total is 22 + N and the interpreted total is 9 + 5N. A short script finishes first when interpreted, because it never pays the build. Any real amount of work finishes first when compiled, because the build is paid once and the interpreter's overhead is paid on every statement. In Demo 2, when the compiled program prints at t = 1022, the interpreter is only at iteration 203.
The ratio in the page's model is 5 to 1. Real tree-walking interpreters are often 10 to 100 times slower than optimised machine code on tight loops; the build, on the other hand, can take seconds or minutes for a large program. Editing the source changes nothing for the interpreter, but the compiler must build again before the edited program can run (Demo 5).
When errors are found
The typo program adds a branch that prints a name that does not exist:
if total > 100:
print(totl)
The compiler checks every line before anything runs, including branches that may never run. At t = 8 it stops with error: 'totl' undeclared: no a.out, nothing runs (Demo 3, Demo 4). The interpreter only looks at a line when it gets there. With N = 3 the total never passes 100, the branch never runs, and the program prints 6 without anyone noticing the bug (Demo 3). With N = 1000 the branch runs in iteration 14, when total = 105, and the program crashes with NameError: name 'totl' is not defined at t = 121 (Demo 4). That is why programs in interpreted languages lean on tests and linters: they find what a compiler's checks would have found.
What the output is tied to
The compiler's output is machine code for one instruction set and one operating system: here an ELF file with x86-64 instructions. Copied to an ARM64 machine, the kernel refuses it: cannot execute binary file: Exec format error (Demo 6). To run there, the program must be compiled again for ARM64, or cross-compiled. The interpreted program is just text: it runs on any machine that has a build of the interpreter, and the interpreter is the part that was compiled for each CPU.
| compiled ahead of time | interpreted | |
|---|---|---|
| start-up | wait for the build | at once |
| speed per statement | a few CPU instructions | fetch, dispatch, evaluate every time |
| errors like an undefined name | before the program runs | when the line runs, maybe never |
| after an edit | build again | run again |
| what you ship | a binary per CPU and OS | source (plus the interpreter) |
Most languages are both
"Compiled language" and "interpreted language" describe implementations, not languages. Most real systems mix the two. A bytecode VM compiles the source to a compact instruction set for a virtual machine, then interprets that: CPython writes .pyc files, javac writes .class files for the JVM. A JIT (just-in-time compiler) starts by interpreting and compiles the hot code to machine code while the program runs, so it gets a quick start and fast loops: HotSpot, V8, PyPy.
| language | usual implementation |
|---|---|
| C, C++, Rust, Go | compiled ahead of time to machine code |
| Java, C# | compiled to bytecode, then interpreted and JIT-compiled by the VM |
| Python (CPython), Ruby (CRuby) | compiled to bytecode, then interpreted (recent versions add a JIT) |
| JavaScript | parsed to bytecode, interpreted, hot functions JIT-compiled (V8, SpiderMonkey) |
See also How a Java Program Is Compiled and Run for every javac phase and the JVM interpreter, HotSpot JIT Tiers for how a VM compiles the hot code while the program runs, and How a C Program Is Compiled, Linked, Loaded and Started for what happens between the compiler and a running a.out.
A lexer is a DFA built from the token regexes; see Regex to NFA to DFA.
One back-end pass, mapping variables onto the few CPU registers, is animated in Register Allocation.
See Inside a Tiny Compiler for a lexer, parser, code generator and stack VM built step by step.
What the page leaves out
Linking and loading (the page's a.out just appears), optimisation levels, register allocation, bytecode and its instruction set, JIT tiers and deoptimisation, garbage collection, and static vs dynamic typing, which is a separate question: a compiler for a dynamically typed language still cannot catch every type error, and some interpreters check names before they run. The tick costs are made up to keep the numbers small; real ratios depend on the language and the code.