// fundamentals · compilers

From source code to running program: compilers, linkers, and the Python VM

A CPU executes only machine instructions — copy, add, jump — and has never heard of Python. This guide maps every translation between your source file and those instructions: the ladder from machine code to high-level languages, the classic compile-assemble-link pipeline, what Python actually does at runtime, and how it manages memory. Four simulators walk one program down the ladder, link two object files, run bytecode on the CPython VM, and watch reference counts free the heap.

mindmap — quick refresh Source → running program — every language ends as machine instructions the ladder of languages machine code: raw CPU-specific bytes (8B 05 …) — all a CPU ever executes assembly: 1-to-1 mnemonics (mov, add); the assembler translates high-level: 1 line → many instructions; portable; needs a translator the classic pipeline (C) compiler: source → assembly, one file at a time assembler: assembly → object file = machine code + holes + symbol table linker: patches holes across files and libraries → one executable loader: OS copies it into memory and jumps to main compiled, interpreted, or both AOT (C, Go, Rust): translate once ahead of time — fastest, per-CPU binary interpreter: translate while running — instant start, portable, slower bytecode + VM (Python, Java): compile to an imaginary CPU, interpret that what Python actually does tokenize → AST → bytecode, automatically, in milliseconds __pycache__/*.pyc = cached bytecode of imported modules CPython VM = stack machine: LOAD, LOAD, BINARY_OP, RETURN dis.dis(fn) shows any function's bytecode — try it how Python manages memory every value is an object on the heap; variables are name→object bindings b = a copies the reference, never the object refcount hits 0 → freed that instant; the gc catches reference cycles no malloc/free to write — the bookkeeping runs at runtime instead

Prerequisites: none — this guide stands on its own. Having run a few Python scripts helps, but the first three sections apply to every language ever made.

Three facts set up everything below. One: your CPU executes a few billion instructions per second, and every single one is trivial — copy a number, add two numbers, compare, jump to another instruction. That's the entire vocabulary. Two: the CPU has never heard of Python, or C, or any language — it executes only its own machine instructions, patterns of bytes baked into the silicon. Three: therefore every program you have ever run, in any language, was translated into those bytes before (or while) it ran. The only questions are who does the translating and when — and the answers to those two questions are what "compiled language", "interpreted language", "linker", and "virtual machine" actually mean. This guide walks the whole path, then follows Python specifically: how python app.py really executes, and how your objects get memory without you ever asking for it.

The ladder of languages

Take one line of Python:

total = price + tax

The CPU cannot execute this — to silicon, it's just text. What it can execute looks like 8B 05 08 10 40 00: a machine instruction, here one plausible x86 encoding of "copy the number at address 0x401008 into a register." The first programmers, in the 1940s, wrote those raw numbers by hand. It worked, and it was miserable — so every language since is a rung on a ladder climbing away from it:

  • Machine code — the raw instruction bytes. Different for every CPU family: x86 bytes mean nothing to the ARM chip in your phone. This is the only rung that runs.
  • Assembly — the same instructions, one-to-one, but written as mnemonics a human can read: mov eax, [price] instead of 8B 05 …. A small program called an assembler does the mechanical byte translation. Still CPU-specific, still one line per instruction.
  • High-level languages — Python, C, JavaScript, and everything else you'd choose on purpose. One line can become many instructions, you think in variables and functions instead of registers and addresses, and the same source runs on any CPU — provided something translates it. That something is a compiler (translates ahead of time) or an interpreter (translates while running).

Watch one line ride the ladder down:

The rungs never went away — they stacked. Every Python program still becomes machine instructions in the end; you've just delegated the paperwork.

Here's the ahead-of-time story, the one C has used since 1972 — worth knowing even if you never write C, because its vocabulary (compile, object file, link) leaks into every build error you'll ever read. Two source files:

/* mathlib.c */                    /* main.c */
int add(int a, int b) {            int add(int a, int b);   /* declared, not defined */
    return a + b;                  int main(void) {
}                                      return add(40, 2);
                                   }

The pipeline runs in four stages. The compiler translates each .c file — separately, knowing nothing about the other — into assembly, and the assembler turns that into an object file (main.o, mathlib.o): real machine code, but with holes. main.o contains a call instruction whose target address is blank, because the compiler had no idea where add would live — it only saw the declaration. Alongside the code, each object file carries a symbol table: a list of names it defines and names it still needs. The linker's whole job is cross-referencing those tables — it finds add in mathlib.o, patches the blank address in main.o, and welds everything into one self-contained executable. Finally the loader (part of the OS) copies that file into memory and jumps to main:

Why bother with the two-step? Separate compilation. A real project has thousands of files; when you edit one, only that one recompiles — the linker just re-welds. And a library is nothing more exotic than a box of pre-compiled object files: you never compile printf, you link against the copy that shipped with your system. When a build fails with undefined reference to 'add', you can now read it literally: the linker finished its cross-reference with a hole still open.

Compiled, interpreted — or both

So when does translation happen? Three answers, three families of languages:

StrategyWhen translatedWhat you shipExamples
Ahead-of-time (AOT) compiledOnce, before runningMachine-code executable, per CPU/OSC, C++, Go, Rust
InterpretedWhile running, every runThe source itselfShell scripts, early BASIC
Bytecode + virtual machineCompiled to bytecode, then interpretedSource or bytecode, runs anywherePython, Java, C#

AOT buys raw speed — all translation is paid before launch, and the CPU runs your instructions directly — at the price of a separate binary per platform and a compile step in your loop. Pure interpretation buys instant starts and total portability at the price of re-translating hot code endlessly. The middle row is the trick most modern languages use: compile the source into bytecode — machine code for an imaginary, idealized CPU — then have a program simulate that CPU. The simulator is called a virtual machine (VM), and the bytecode is portable because the imaginary CPU is the same on every real one. (The fourth idea, JIT compilation, watches the VM run and compiles the hottest bytecode into real machine code on the fly — it's how Java got fast, and CPython has been growing an experimental one since 3.13. One sentence is all it needs here.)

Which brings us to the actual question: which row is Python in? The folklore answer — "Python is interpreted" — is only half true.

What Python actually does with your code

When you run python app.py, CPython (the standard Python, written in C) does real compiler work before executing anything: it tokenizes your source (total, =, price, +, tax), parses the tokens into a syntax tree, and compiles that tree into bytecode. All of it automatic, hidden, and fast — milliseconds for a typical file. You've already seen the evidence: the __pycache__ folder that appears next to your modules holds .pyc files, which are exactly this bytecode, cached so imports skip the recompile next time.

And the bytecode is no secret — the standard library will disassemble any function for you:

>>> import dis
>>> def total(price, tax):
...     return price + tax
...
>>> dis.dis(total)
  1           RESUME                   0

  2           LOAD_FAST                0 (price)
              LOAD_FAST                1 (tax)
              BINARY_OP                0 (+)
              RETURN_VALUE

(RESUME is internal bookkeeping — ignore it.) Four instructions for our one line, and they read like assembly for a CPU that doesn't exist: the CPython VM, a stack machine. It has no registers; instructions push values onto a stack and pop them off. LOAD_FAST pushes a local variable, BINARY_OP pops two values and pushes their sum, RETURN_VALUE pops the answer and hands it to the caller. The VM itself is, at heart, a loop in C: fetch the next bytecode instruction, do the small thing it says, repeat. Watch it run:

Now the folklore can be corrected precisely: Python is compiled and interpreted. Compiled — automatically — from source to bytecode; interpreted from bytecode by the VM. The reason Python is slower than C isn't "no compiler," it's where translation stops: C's pipeline runs all the way down to real machine instructions before launch, while each Python bytecode still costs a trip around the VM loop — tens of real instructions to perform one imaginary one. That's the interpretation tax, and it buys the things you use Python for: no build step, same code on every machine, and types checked while running instead of declared in advance.

How Python manages memory

One more thing happens at runtime that C programmers do by hand: memory. In C you request bytes (malloc) and must release them (free) — forget, and the program leaks; release twice, and it crashes. Python removed both verbs. Here's what replaced them.

Every value lives on the heap; variables are just names. When you write a = [1, 2, 3], Python builds a list object in a region of memory called the heap, and binds the name a to it. A name is a label tied to an object — not a box containing it. So assignment copies labels, never objects:

a = [1, 2, 3]
b = a            # no copy — a second label on the SAME object
b.append(4)
print(a)         # [1, 2, 3, 4]  ← surprise, unless you know the label rule

Reference counting frees the dead. Every object carries a counter of how many references point at it — bind a name, pass it to a function, put it in a list: count goes up; del a name, rebind it, leave the function: count goes down. The moment the count hits zero, CPython reclaims the object's memory immediately — no waiting, no magic. You can even watch the counter: sys.getrefcount(a) (it reports one extra, since passing a to the function is itself a reference).

A garbage collector catches the one case counting can't. Build a cycle — x = []; x.append(x), a list containing itself — then delete x. The list still holds a reference to itself, so its count is stuck at 1, yet nothing in your program can reach it. Pure refcounting would leak it forever. CPython's cyclic garbage collector (the gc module) runs periodically, hunts down groups of objects that only reference each other, and frees them. Watch both mechanisms work:

That's the trade in one sentence: C makes you do the bookkeeping and charges nothing at runtime; Python does the bookkeeping for you and charges a little on every operation — the same bargain as the VM itself, paid in the same currency, bought for the same reason.

Takeaways

  • CPUs execute machine instructions, nothing else — every language is a scheme for producing them; "compiled vs interpreted" only asks who translates, and when.
  • The classic pipeline is compile → assemble → link → load — object files are machine code with holes plus a symbol table; the linker cross-references the tables and welds one executable. undefined reference = a hole nobody filled.
  • A VM is a CPU made of software — bytecode is machine code for that imaginary CPU, portable because the imaginary CPU is identical everywhere.
  • Python compiles, then interprets — source → tokens → AST → bytecode (cached in __pycache__), executed by the CPython stack machine. dis.dis() shows you any function's bytecode; use it once and the mystery never returns.
  • Names are labels, not boxesb = a aliases one heap object; mutation through either name is visible through both.
  • Memory frees itself, two ways — refcount hits zero → reclaimed instantly; unreachable cycles → swept by the periodic garbage collector. You write no free, and pay for that convenience at runtime.

References

  • Aho, A. V., Lam, M. S., Sethi, R., & Ullman, J. D. (2007). Compilers: Principles, techniques, and tools (2nd ed.). Pearson.
  • Bryant, R. E., & O'Hallaron, D. R. (2016). Computer systems: A programmer's perspective (3rd ed.). Pearson.
  • Nystrom, R. (2021). Crafting interpreters. Genever Benning. https://craftinginterpreters.com/
  • Python Software Foundation. (n.d.). dis — Disassembler for Python bytecode (Python documentation). Retrieved August 20, 2026, from https://docs.python.org/3/library/dis.html
  • Python Software Foundation. (n.d.). gc — Garbage collector interface (Python documentation). Retrieved August 20, 2026, from https://docs.python.org/3/library/gc.html
  • Python Software Foundation. (n.d.). Memory management (Python/C API reference manual). Retrieved August 20, 2026, from https://docs.python.org/3/c-api/memory.html
  • Python Software Foundation. (n.d.). What's new in Python 3.13: An experimental just-in-time (JIT) compiler. Retrieved August 20, 2026, from https://docs.python.org/3/whatsnew/3.13.html