Python Execution Internals

Running a Python program is a pipeline — source text becomes a syntax tree, the tree compiles to bytecode, and a virtual machine executes that bytecode instruction by instruction.

When you run python script.py, a lot happens before your first line of logic executes. Python source text is tokenized and parsed into a tree representing its grammatical structure, that tree is compiled into a compact instruction set called bytecode, and a virtual machine loop executes those instructions one at a time, manipulating objects on a stack and in namespaces. Understanding this pipeline demystifies error messages, explains why compiled .pyc files exist, and clarifies what 'the Python interpreter' actually is.

What Is the Execution Pipeline

At a high level, running Python source goes through four stages: lexing/parsing (source text to a syntax tree), compilation (syntax tree to bytecode, a low-level instruction set), loading (the compiled code object is prepared for execution, including creating the frame it will run in), and interpretation (a virtual machine loop executes the bytecode instructions, one at a time, against a stack and the relevant namespaces).

source code (.py text)
      |  tokenizer + parser
      v
Abstract Syntax Tree (AST)
      |  compiler
      v
bytecode (code object)
      |  interpreter / virtual machine loop
      v
running program (objects created, names bound, output produced)

Source Code to Parser

The first stage tokenizes the raw source text into a stream of tokens (keywords, identifiers, operators, literals, indentation markers) and then parses that token stream according to Python's grammar into a tree structure representing the program's syntax. A SyntaxError — mismatched parentheses, invalid indentation, a stray keyword — is raised here, before any of your code's logic has a chance to run.

You can trigger just this stage (parsing without compiling to bytecode or running anything) using the built-in compile() with mode arguments, or, for a structured, inspectable view of the result, the standard-library ast module (see the related AST topic).

The Abstract Syntax Tree

The parser's output is an Abstract Syntax Tree (AST): a tree of nodes where each node represents a grammatical construct — a function definition, a binary operation, an if statement, a name reference. 'Abstract' means the tree discards syntactic noise irrelevant to meaning (redundant parentheses, exact whitespace) while keeping everything semantically meaningful. This is a conceptual overview; the ast module topic covers inspecting and transforming this tree directly.

ast.parse performs exactly the parser stage, returning the syntax tree as an inspectable Python object graph.

import ast

tree = ast.parse("x = 1 + 2")
print(ast.dump(tree, indent=2))

AST to Bytecode: Compilation

The compiler walks the AST and emits bytecode: a sequence of simple, low-level instructions (like 'load this name onto the stack', 'call this function', 'compare the top two stack values') packaged into a code object along with metadata (constants used, variable names, the function's argument count). This is a genuine compilation step — bytecode is not Python source text, it is a separate lower-level representation — but it targets a portable virtual machine rather than a specific CPU's native instructions, unlike a traditional ahead-of-time compiler for C.

The exact bytecode instruction set (its opcodes, their names, and even how many instructions a given construct compiles to) is a CPython implementation detail that changes between Python versions — it is not part of the Python language specification. See the related Bytecode topic for hands-on inspection with dis.

The Virtual Machine Concept

The compiled bytecode is executed by a virtual machine (VM): an interpreter loop that reads one bytecode instruction at a time, does whatever small operation it specifies (push a value, pop two values and add them, call a function, jump to another instruction on a condition), and moves to the next instruction. This loop, not your source text, is what's actually 'running' when a Python program executes. Each function call creates a frame (see the related Frames & Call Stack topic) that holds the VM's local state — its stack of intermediate values and its local variables — for that particular call.

This stack-machine model is conceptually similar to how a physical CPU executes machine code, but at a much higher level of abstraction: bytecode instructions manipulate Python objects and Python-level concepts (attribute access, exception handling, iterator protocols) rather than raw bytes and registers.

Runtime: Objects, Namespaces, and the Interpreter State

'Runtime' refers to everything that exists and operates while the program is executing: the objects created so far, the namespaces (dictionaries mapping names to objects) that bytecode instructions look names up in, the call stack of active frames, and interpreter-level state like the exception currently being handled. Bytecode instructions like LOAD_NAME/LOAD_FAST/STORE_NAME are exactly the mechanism by which Python's name-binding model (see the related Namespaces & Scope topics) is implemented at this level.

Compiled Artifacts: .pyc Files

Because parsing and compiling source to bytecode has a real cost, CPython caches the compiled bytecode for imported modules on disk as .pyc files (under a __pycache__ directory, named with the Python version, e.g. module.cpython-312.pyc). On a later import, CPython checks the source file's modification time/hash against the cached bytecode and skips straight to loading it if unchanged, skipping the parse-and-compile stages. This caching is a CPython implementation detail for performance — it's invisible to the language semantics; the script you run directly (python script.py) is typically compiled fresh each run rather than cached the same way imported modules are.

Implementation Differences

Everything above describes CPython, the reference implementation and the one almost everyone means by 'Python'. Other implementations follow the same *language* (parse source, produce some intermediate form, execute it) but differ enormously in mechanism:

None of the compilation/bytecode/VM mechanics above are guaranteed by the Python language specification — they are how CPython happens to work. A program that depends on CPython bytecode details (rather than on documented language behavior) is not portable to these other implementations.

Implementation | Execution strategy
----------------|----------------------------------------------------
CPython         | AST -> bytecode -> stack-based bytecode interpreter
PyPy            | AST -> bytecode -> interpreter, PLUS a JIT that compiles
                | hot code paths to native machine code at runtime
Jython           | compiles to JVM bytecode, runs on the Java Virtual Machine
IronPython       | compiles to CLR bytecode, runs on .NET's runtime
GraalPy          | runs on GraalVM, with optional JIT compilation

Practical Usage: Observing Each Stage

You can inspect each stage of the pipeline directly from Python itself, which is useful for understanding performance, debugging subtle bugs, or simply building intuition for what 'running' code really means.

compile() alone performs parsing + compilation without running anything; exec() then runs the resulting code object; dis.dis shows the bytecode the VM will execute.

import ast
import dis

source = "def add(a, b):\n    return a + b"

tree = ast.parse(source)              # stage 1: parsing
print(ast.dump(tree)[:80], "...")

code_obj = compile(source, "<string>", "exec")   # stage 2: compilation
exec(code_obj)                                    # defines add() at module scope

dis.dis(add)                          # stage 3: inspect the bytecode

Edge Cases and Common Confusions

"Interpreted" vs "compiled" is not a clean binary for Python: CPython genuinely compiles source to bytecode (a real compilation step) before interpreting that bytecode — it just doesn't compile all the way down to native machine code ahead of time the way C or Rust does. Calling Python 'purely interpreted' understates this; calling it 'compiled' without qualification overstates it and invites confusion with ahead-of-time native compilation.

A SyntaxError always happens at the parsing stage, before any bytecode from that module runs — this is why a syntax error deep inside a function body that's never called can still prevent the whole module from importing.

Mental Model

Think of Python source as sheet music, the AST as a structural analysis of that music's phrasing (which notes belong to which phrase), bytecode as a simplified, mechanical performance script derived from that analysis, and the virtual machine as the musician executing that script one instruction at a time, maintaining state (its 'stack' of what it's currently holding) as it goes.

Quick Reference

Stage        | Tool to observe it directly
--------------|---------------------------------------
Parsing       | ast.parse(source) -> AST
Compilation   | compile(source, "<string>", "exec") -> code object
Execution     | exec(code_obj) / running the program
Bytecode view | dis.dis(func_or_code)
Cached form   | __pycache__/*.cpython-3XX.pyc (CPython only)

Key Takeaways

Running Python source goes through parsing (to an AST), compilation (AST to bytecode), and interpretation (a virtual machine executing that bytecode).

A SyntaxError is raised at the parsing stage, before any of the module's code runs.

The exact bytecode instruction set and its execution loop are CPython implementation details, not guaranteed language behavior.

.pyc caching of compiled bytecode is a CPython performance optimization, invisible to program semantics.

Other implementations (PyPy, Jython, IronPython, GraalPy) implement the same language with entirely different execution strategies, including JIT compilation to native code in PyPy's case.

Python is neither purely 'interpreted' nor 'compiled' in the traditional native-machine-code sense — it compiles to bytecode, then interprets that bytecode.

"Python has no compilation step, it's a purely interpreted language."

"CPython compiles source to bytecode (an intermediate representation), then a virtual machine interprets that bytecode — it's not compiled to native machine code ahead of time, but it is genuinely compiled to an intermediate form first."

Calling Python 'purely interpreted' ignores the real AST-to-bytecode compilation step CPython performs before execution.

"Python bytecode instructions are the same across every version and implementation."

"Bytecode is a CPython implementation detail: opcodes, their names, and how constructs compile to them change between Python versions and differ entirely in other implementations."

Treating bytecode as a stable, portable specification is wrong — code that depends on specific bytecode is fragile across versions and not portable to PyPy/Jython/etc.

List the four stages source code passes through before your program's logic executes.

Lexing/parsing into an AST, compilation of the AST into bytecode, loading (preparing the code object and initial frame), and interpretation (the virtual machine executing the bytecode).

At which stage is a SyntaxError raised, and why does that mean it can happen even for code that's never called?

At the parsing stage, before any bytecode runs — the whole module must parse successfully before any of it executes, so a syntax error inside an uncalled function still prevents the module from importing.

Why is calling Python 'purely interpreted' technically inaccurate?

CPython genuinely compiles source into an intermediate bytecode representation before executing it; it just doesn't compile bytecode further into native machine code ahead of time the way C does.

Why are exact bytecode instructions considered a CPython implementation detail rather than language behavior?

The Python language specification does not define bytecode; it's specific to how CPython happens to implement execution, and it changes across CPython versions and differs entirely in other implementations like PyPy or Jython.

What is the purpose of .pyc files, and why are they invisible to program semantics?

They cache the compiled bytecode for imported modules on disk so re-importing skips re-parsing and re-compiling unchanged source; they only affect performance, not what the program computes.

How does PyPy's execution strategy differ from CPython's, at a high level?

PyPy also compiles to bytecode and interprets it, but additionally includes a JIT compiler that detects hot code paths at runtime and compiles them to native machine code for speed.

What tool would you use to see the raw AST for a piece of source code without running it?

ast.parse(source), from the standard-library ast module, which performs only the parsing stage and returns an inspectable tree object.

Write code that takes the source string 'x = 1\ny = x + 2' and performs only the parse stage, printing the resulting AST.

import ast source = "x = 1\ny = x + 2" tree = ast.parse(source) print(ast.dump(tree, indent=2)) ast.parse tokenizes and parses the source into an AST Module node without compiling to bytecode or executing anything; ast.dump renders that tree as readable text.

What kind of output does dis.dis(add) produce, and what stage of the execution pipeline does it expose?

It prints the function's bytecode instructions (like LOAD_FAST, BINARY_OP, RETURN_VALUE) with their line numbers and offsets — this exposes the compiled-bytecode stage, after parsing/compilation and before interpretation.

Write code using compile() and exec() to compile the source 'print("hi")' into a code object and then run it, without using exec(source_string) directly on the raw string.

source = 'print("hi")' code_obj = compile(source, "<string>", "exec") exec(code_obj) compile() performs parsing and compilation, producing a code object (bytecode plus metadata) without running anything; exec(code_obj) then drives the virtual machine to execute that bytecode, printing 'hi'.

import dis

def add(a, b):
    return a + b

dis.dis(add)