Compilers vs. Interpreters: How Your Code Becomes Execution๐
Part of a deep dive and a pathway: Languages & Parsing How Modern Software Really Runs on a CPU
Consult the map
-
Languages & Parsing โ step 5 of 5
โ How Parsers Work ยท you are here ยท (last step) โ
-
How Modern Software Really Runs on a CPU โ step 2 of 17
โ What Actually Happens When Your Code Runs ยท you are here ยท The Stack, the Heap, and Virtual Memory โ
"Python is interpreted, Go is compiled" is one of those lines repeated so often it's stopped meaning anything specific. Running go build produces a binary; running python app.py does something else entirely. But what, exactly?
The question matters more than trivia. It explains why Go programs start instantly and Python programs spin up a virtual machine. It explains why TypeScript errors appear in your editor before you run anything. It explains why your Java stack trace points to a source file line number even though the JVM is running bytecode. And it explains what actually happens to those 500 lines of Python you just wrote before a single one executes.
Learning Objectives
By the end of this article, you'll be able to:
- Describe the compilation pipeline: source code โ tokens โ AST โ IR โ machine code
- Distinguish compilers, interpreters, bytecode VMs, and JIT compilers. And give real examples of each
- Explain why compiled and interpreted languages report errors at different times
- Connect build times, deployment artifacts, and stack trace formats to the compile/interpret choice
- Reason about the performance trade-offs between ahead-of-time compilation and JIT compilation
Where You Might Have Seen This๐
The compile/interpret distinction shapes your daily development workflow:
- Build times:
cargo buildtakes minutes on a large Rust project;python script.pystarts instantly. That asymmetry is compilation work happening at different times. - Error timing: a Go or Rust type error appears during
build, before any code runs; a PythonTypeErrorappears at runtime, only when that code path executes - Deployment artifacts: shipping a Go service means shipping a single binary; shipping a Python service means shipping source files plus a Python runtime
- Docker image size: Go programs compile to a single static binary that runs in a
FROM scratchcontainer; Python requires the interpreter, stdlib, and all dependencies - IDE autocomplete: TypeScript's language server can autocomplete and flag errors because it compiles your code in the background as you type
pycfiles: the.pycfiles Python creates in__pycache__are bytecode, an intermediate form between source and machine code; Python is more complex than "purely interpreted"
Why This Matters for Production Code๐
Compiled languages (C, C++, Rust, Go) translate your source code into native machine instructions before the program runs. At runtime, the CPU executes those instructions directly. No translation overhead.
Interpreted languages (classic Python, Ruby, original JavaScript) parse and execute your source code instruction by instruction at runtime. Each operation involves the interpreter figuring out what to do: function lookup, type checking, dispatch, work that compiled languages already resolved at build time.
The performance difference can be 10โ100ร for compute-bound workloads. This is why game engines, database internals, and operating system kernels are written in C/C++/Rust, while web frameworks and scripting are comfortable in Python/Ruby.
Modern JIT-compiled languages (JVM languages, PyPy, V8 JavaScript) narrow this gap by compiling hot code paths to native machine code at runtime, trading startup time for sustained performance.
When compilation happens affects when you discover bugs:
| Error type | Compiled (before run) | Interpreted (at runtime) |
|---|---|---|
| Type mismatch | Always caught | Only if that code path runs |
| Missing variable | Always caught | Only when that line executes |
| Syntax error | Always caught | Potentially at startup |
| Logic error | Never caught by either | Only when triggered |
This is why "interpreted" and "dynamically typed" tend to go together: without a compilation step, type checking has to happen at runtime anyway. And it's why adding TypeScript to JavaScript (a compiled type-checking layer over an interpreted language) caught so many real bugs: it moved type errors from runtime to compile time.
Compiled binaries are architecture-specific: code compiled for Linux/amd64 won't run on macOS/arm64 without recompilation. This is why Go and Rust projects use cross-compilation targets (GOOS=linux GOARCH=amd64 go build).
Interpreted languages trade this for portability: Python code runs on any platform with a Python interpreter. The bytecode formats (.pyc, .class) add another layer: bytecode compiled once runs on any machine with the right virtual machine. This is the "Write Once, Run Anywhere" proposition Java made in 1995.
Interpreters can provide extremely precise error location: "line 47 of utils.py" because the interpreter knows exactly which source line it's currently executing. Source maps in transpiled JavaScript provide the same experience.
Compiled languages without debug symbols lose source-level information entirely. The binary just knows addresses. Debug builds (-g flags) embed source location information back into the binary so debuggers can show you source lines. The production binaries you ship usually strip this for size.
What a Compiler Actually Does๐
A compiler is a program that translates source code in one language into equivalent code in another language. Usually the target is machine code (native instructions for a specific CPU), but it could also be bytecode or even another high-level language.
The compilation pipeline has distinct phases:
graph LR
A["Source Code\n(.go, .rs, .c)"]:::standard --> B["Lexer\n(tokenize)"]:::standard
B --> C["Parser\n(AST)"]:::standard
C --> D["Semantic\nAnalysis"]:::standard
D --> E["Code\nGeneration"]:::standard
E --> F["Machine Code\n(binary)"]:::accent
classDef standard fill:#2d3748,stroke:#cbd5e0,stroke-width:2px,color:#fff
classDef accent fill:#326CE5,stroke:#cbd5e0,stroke-width:2px,color:#fff
- Lexing (tokenizing). The source text is split into tokens: keywords (
func,if), identifiers (userName), operators (+,:=), literals (42,"hello") - Parsing: tokens are arranged into an Abstract Syntax Tree (AST) that represents the grammatical structure of the program (see How Parsers Work)
- Semantic analysis: type checking, name resolution, scope analysis; this is where
undefined variableand type mismatch errors come from - Code generation. The verified AST is translated into machine instructions or an intermediate representation
All of this happens before your program runs. When it fails, you get a compile error. When it succeeds, you get a binary.
What an Interpreter Actually Does๐
An interpreter does all the same analysis (parsing, type checking, name resolution) but does it at runtime, just before executing each piece of code.
The simplest model: the interpreter reads one statement, executes it, reads the next, executes it, and so on. This is the "live translator" mental model.
graph LR
A["Source Code"]:::standard --> B["Parse to AST"]:::standard
B --> C["Walk AST\n(execute as you go)"]:::accent
C --> D["Output / Side Effects"]:::standard
classDef standard fill:#2d3748,stroke:#cbd5e0,stroke-width:2px,color:#fff
classDef accent fill:#326CE5,stroke:#cbd5e0,stroke-width:2px,color:#fff
The consequence: every time the program runs, the interpreter does this work again from scratch. This is the source of the startup overhead you see when you python -c "print('hi')": Python is parsing the file, building an AST, and setting up the execution environment before print even runs.
Why You Can Hot-Reload a Flask App, But Not a Go Binary๐
Save a change to a Python or JavaScript file and, in most dev setups, the running app just... updates. Save a change to a .go or .rs file and nothing happens until you rebuild and restart it. That difference isn't a tooling gap Go and Rust haven't gotten around to closing. It's downstream of everything above.
An interpreter is a running program that keeps treating your source (or its bytecode) as data it reads on every call, not as something it's already consumed and discarded. Data can be swapped out mid-flight. A compiler performs a one-way translation into opcodes the CPU executes directly, and once that translation happens, the running process no longer has any concept of "source" left to consult. There's nothing left to swap.
That said, "hot-reload" covers a few genuinely different things, worth telling apart:
-
A full, fast, automated restart. What Flask's or Django's dev-server reloader actually does. It watches your files and kills-and-respawns the whole process the moment one changes. It feels live because it's instant and automatic, but it's a restart, not a modification of the running process: any in-memory state you hadn't persisted is gone.
Easy to prove, not just assert. A trivial Flask app returns its own PID and an in-memory counter that increments per request:
Before and after touching the file, no code change needed$ curl 127.0.0.1:5055/ pid=1602188 counter=1 $ curl 127.0.0.1:5055/ pid=1602188 counter=2 $ touch app.py # server.log: "Detected change in 'app.py', reloading" / "Restarting with stat" $ curl 127.0.0.1:5055/ pid=1603138 counter=1New PID, counter back to 1. That's not a patch to the running process โ it's a different process, with Werkzeug's own log calling it "restarting," not "reloading in place."
-
REPL-style redefinition. A Python, Ruby, or Node REPL (or a Jupyter kernel) lets you redefine a function directly, and the next call uses the new version, in the same process, with all its state intact. This is closer to true live editing.
- Real hot code swapping. Erlang and Elixir's BEAM VM can replace a running module's code with zero downtime and no state loss, a capability the language was explicitly designed around (it's how telecom systems patch live call-handling code without dropping calls). Smalltalk and Lisp environments work the same way, treating the running program as a "live image" you edit in place rather than a fixed artifact you rebuild.
Compiled languages have nothing in this list, structurally. Not because nobody's built the tooling, but because the running process genuinely is machine code sitting in memory with no source left to point back to. Tools like air or CompileDaemon for Go just automate the rebuild-and-restart cycle faster; they're doing the same restart Flask's reloader does, for a language where "restart" is the only option to begin with.
Error Timing in Practice๐
The most concrete difference between compiled and interpreted languages is when a type error gets caught. The same logical mistake (passing a number where a string is expected) is caught at completely different points:
| Type Error at Runtime (Python) | |
|---|---|
When caught: runtime, only if the code path executes.
When caught: never in JavaScript (silent coercion). TypeScript catches it before any code runs.
| Type Error at Compile Time (Go) | |
|---|---|
When caught: compile time. go build rejects this before a single instruction executes.
| Type Error at Compile Time (Rust) | |
|---|---|
When caught: compile time. The Rust compiler rejects the mismatch before producing any output.
| Type Error at Compile Time (Java) | |
|---|---|
When caught: compile time. javac rejects this before the JVM processes it.
| Type Error at Compile Time (C++) | |
|---|---|
When caught: compile time. The C++ compiler rejects the type mismatch before producing any object code.
The Hybrid Reality: Bytecode and Virtual Machines๐
Modern "interpreted" languages rarely operate purely as described above. Python, Ruby, Lua, and the JVM languages all use a hybrid approach:
- Compile to bytecode. The source is compiled to a compact intermediate representation (Python's
.pycfiles, Java's.classfiles). This step catches syntax errors and does some optimization. - Execute bytecode in a virtual machine: a specialized interpreter reads bytecode instructions (which are simpler and more uniform than source code) and executes them.
graph LR
A["Source Code\n(.py, .java)"]:::standard --> B["Bytecode Compiler"]:::standard
B --> C["Bytecode\n(.pyc, .class)"]:::standard
C --> D["Virtual Machine\n(CPython, JVM)"]:::accent
D --> E["Execution"]:::darker
classDef standard fill:#2d3748,stroke:#cbd5e0,stroke-width:2px,color:#fff
classDef accent fill:#326CE5,stroke:#cbd5e0,stroke-width:2px,color:#fff
classDef darker fill:#1a202c,stroke:#cbd5e0,stroke-width:2px,color:#fff
Java's bytecode portability is the design intention: compile once to .class files, run on any JVM on any platform. Python's .pyc files are a performance optimization: avoid re-parsing the source file on the next run.
JIT Compilation: The Best of Both๐
Just-In-Time (JIT) compilation takes the hybrid approach further: the virtual machine monitors which code runs frequently ("hot paths") and compiles those specific pieces to native machine code at runtime.
The result: JIT-compiled code starts with interpreted performance (slow), then approaches native performance as the JIT identifies and compiles hot paths.
- V8 (Node.js, the Chrome browser) JIT-compiles JavaScript. This is why Node.js is fast enough for production servers despite JavaScript being "interpreted"
- JVM HotSpot JIT-compiles Java bytecode: mature Java applications often match C++ performance after warmup
- PyPy is a JIT-compiled Python implementation: 5โ50ร faster than CPython for compute-bound code
The trade-off: JIT compilation adds complexity and startup cost. This is why Lambda functions and short-lived containers pay a "cold start" penalty. The JIT hasn't had time to optimize the hot paths yet.
Summary Table๐
| Approach | When translation happens | Examples | Trade-offs |
|---|---|---|---|
| Compiled | Before runtime | Go, Rust, C, C++ | Fast runtime; slow build; early error detection |
| Interpreted | At runtime | Classic Python, Ruby, Shell | Flexible; portable; slower; late error detection |
| Bytecode VM | Bytecode at build, execution at runtime | CPython, JVM languages | Portable bytecode; moderate performance |
| JIT | Hot paths at runtime | V8, HotSpot, PyPy | Near-native performance after warmup; cold start cost |
| Transpiled | Build time, target is another language | TypeScript โ JS, Babel | Source-level benefits; adds build step |
Squint at that table and it collapses into three real tiers, not five. C++, Go, and Rust hand the CPU real opcodes directly, at compile time: nothing stands between your code and the hardware at runtime. Python and Java hand a different virtual machine's bytecode to its own interpreter instead, itself a real, compiled program running its own fetch-decode-execute cycle on your behalf, every single call. JavaScript in Node does the same through V8, with a JIT built in from the start to try to erase the difference for code that runs often enough. Nobody skips the CPU's fetch-decode-execute cycle: some of you are just paying for an extra layer of it, invisibly, until a JIT compiler decides that layer is worth removing.
What's Next๐
You know how source becomes instructions. What's still missing is where those instructions actually live while they run, and why a process can trust that its memory is its own. The Stack, the Heap, and Virtual Memory picks that up next.
Technical Interview Context๐
The compile/interpret distinction is relevant for system design interviews, language choice discussions, and questions about build pipelines and deployment.
Why does Go start up faster than Python?
Go compiles to native machine code ahead of time; the resulting binary runs directly on the CPU. Python parses source and compiles to bytecode on first run (caching the result in __pycache__), then starts the CPython VM on every invocation. The difference is when translation work happens: AOT vs. at runtime.
What causes Lambda / container cold start latency?
JIT-compiled runtimes (JVM, Node.js V8) need time to identify hot paths and compile them to native code. A freshly initialized container starts with interpreted bytecode execution; cold start latency is the cost of the JIT not yet having warmed up.
What is AOT vs JIT compilation?
Ahead-of-time (AOT) compiles before the program runs (Go, Rust, C); the binary is fully optimized at build time. Just-in-time (JIT) compiles hot paths at runtime (JVM, V8, PyPy); startup is slower but the JIT can optimize based on actual runtime behavior.
Why does TypeScript need a build step if JavaScript doesn't?
TypeScript adds a compilation phase that type-checks your code and emits JavaScript. This catches type errors at build time (before any code runs) at the cost of a build step. It's a compiled layer over an interpreted language: the type safety of static analysis without changing the runtime.
Practice Problems๐
Practice 1: Choosing a Compilation Strategy
You're building a CLI tool that will be distributed to users across macOS, Linux, and Windows. It needs to start in under 100ms and have no runtime dependencies (no "please install Python first").
Which approach (compiled, bytecode VM, or interpreted) best fits this use case? Why?
Answer
Compiled (Go or Rust are popular choices for exactly this use case).
- No runtime dependencies: a compiled binary is self-contained
- Sub-100ms startup: no interpreter or VM startup overhead
- Cross-platform: compile separate binaries for each target OS/arch (
GOOS=linux GOARCH=amd64 go build)
Tools like kubectl, gh (GitHub CLI), ripgrep, and exa are all compiled Go or Rust for these exact reasons.
Practice 2: Diagnosing Error Timing
For each scenario, identify whether the error would be caught at compile time or at runtime, and why.
a. In TypeScript: calling a method that doesn't exist on a type
b. In Python: dividing by zero in a function that's only called when the user provides input 0
c. In Go: assigning a string to a variable declared as int
d. In any language: an infinite loop with no exit condition
Answer
a. Compile time: TypeScript's compiler checks method existence against type definitions during tsc; error appears before any code runs.
b. Runtime: Python only executes this code path when the user provides 0; the error appears as a ZeroDivisionError at runtime, only when triggered.
c. Compile time: Go is statically typed; go build rejects this type mismatch before the program runs.
d. Neither: neither compilers nor interpreters can detect arbitrary infinite loops (this is the Halting Problem; it's theoretically undecidable). The program just runs forever.
Key Takeaways๐
| Concept | What to Remember |
|---|---|
| Compiler | Translates entire program to machine code before runtime; errors at build time |
| Interpreter | Translates and executes line by line at runtime; errors at execution time |
| Bytecode | Intermediate form between source and machine code; portable across platforms |
| Virtual Machine | The interpreter that runs bytecode (CPython, JVM, CLR) |
| JIT | Compiles hot code paths to native instructions at runtime; near-native speed after warmup |
| Static typing + compiled | Type errors caught before runtime; why Go/Rust/TypeScript catch bugs early |
| Cold start | Cost of JIT warmup or interpreter startup; matters for serverless and short-lived containers |
Further Reading๐
On This Site
- How Parsers Work. The lexing and parsing phases that both compilers and interpreters share
- Finite State Machines. The theory behind lexers; how source text is tokenized
- Regular Expressions. The pattern matching that drives tokenization
External
- Introduction to Computing by David Evans, Chapter 3. The motivation for formal languages over natural languages, and Scheme as an example of a language designed for interpretation
- Crafting Interpreters by Robert Nystrom: a complete walk-through of building both a tree-walking interpreter and a bytecode VM
The next time someone says "Python is slow" or "Go is fast," you now know what that actually means: it's a statement about when translation work happens and what the runtime overhead of that translation is. Neither approach is universally better: they're different trade-offs for different problems.