Filter concepts by levelShowing all levels.

Python · Python Internals

Execution model

Concepts
7

What actually happens when a function is called — the frame it runs in, the namespace it resolves names against, how a nested function keeps access to an enclosing variable, and why that lookup happens later than most people expect.

Calls, the stack, and namespaces

What a single function call does, the stack of frames that accumulates across nested calls, and the two namespaces a name can resolve against.

Function calls

standardintermediate

Calling f(args) creates a new frame, binds each argument to its parameter name inside that frame, runs the function body, and destroys the frame when it returns — handing the return value back to the caller.

Think of it as

Calling a function is checking out a fresh whiteboard just for that call, writing the argument values on it under the parameter names, working through the function body using only what is on that board (plus anything visible from outside), and wiping the board the moment the function returns — after copying the final answer onto the caller's own board first.

python
def f(a, b):
    return a + b

f(2, 3)   # creates a frame, binds a=2 b=3, runs the body, returns 5, discards the frame

What we're doing: Show that every call to the same function gets its own independent frame, even for recursive calls, by inspecting each frame's local namespace.

calls.pypython
import sys

def countdown(n):
    frame = sys._getframe()
    print(f"n={n}, this frame's id={id(frame)}")
    if n > 0:
        countdown(n - 1)


countdown(2)
4
sys._getframe() returns THIS call's own frame object.
6
countdown(n - 1) is a new call — even though it is the same function, it gets a completely separate frame.
Output
n=2, this frame's id=...
n=1, this frame's id=...
n=0, this frame's id=...

Why this works: Each call to countdown(n) — even recursive ones calling the exact same function object — creates its own fresh frame with its own local namespace, so n=2's frame is a completely different object from n=1's frame, each with its own independent value for n. This is why recursion works at all: if calls shared a frame, every recursive call would stomp on the same n instead of each having its own.

Assuming a default argument is re-evaluated on every call

Wrong

python
def append_item(item, items=[]):   # default evaluated ONCE, at def time
    items.append(item)
    return items

print(append_item("a"))
print(append_item("b"))   # assumed a fresh [] — it is not

Better

python
def append_item(item, items=None):
    if items is None:
        items = []   # a fresh list, created fresh on EVERY call
    items.append(item)
    return items

print(append_item("a"))
print(append_item("b"))   # ['b'] — genuinely independent

What you see: append_item('b') prints ['a', 'b'], not ['b'] — the 'fresh' default list from the first call is still there on the second.

Why: A default argument value is evaluated exactly once, when the def statement itself runs — not once per call. items=[] creates ONE list object that every call without an explicit items argument reuses and mutates. This is a fact about how def builds the function object, not about how each individual call binds its frame — each call's frame is still fresh, but a mutable default's shared reference is bound into that fresh frame every time.

What a call to f(a, b) does, step by step

What a call to f(a, b) does, step by step
StepWhat happens
1. Evaluate argumentseach argument expression is evaluated in the CALLER's frame
2. Create a framea new, empty frame for this specific call
3. Bind parameterseach argument value is bound to its parameter name in the new frame
4. Run the bodystatements execute using that frame's local namespace
5. Returnthe frame is discarded; the return value goes back to the caller

Together

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

result = add(2, 3)   # a=2, b=3 bound in a new frame; frame discarded after returning 5
print(result)

Remember: Every call to f(args) creates a brand-new frame, binds arguments inside it, runs the body, then discards the frame. Recursive calls never share a frame.

See also: call stack and frames · local and global namespaces · defining functions

Call stack and frames

coreintermediate

A frame holds one function call's state — its locals, current line, and a link back to the caller's frame. The call stack is the chain of frames for every active call, growing per call, shrinking per return.

Think of it as

A stack of sticky notes on a desk, one per function call currently in progress. Calling a function sticks a new note on top, listing that call's own local variables and current line. Returning peels the top note off, revealing whatever call was waiting underneath. A traceback is just reading every note on the stack from top to bottom.

python
import sys
frame = sys._getframe()   # the current frame
frame.f_back                # the caller's frame — walk this chain to see the whole stack
frame.f_locals               # this frame's local variables

What we're doing: Walk the call stack from inside a three-levels-deep call, using each frame's f_back link, to see every function currently active.

call_stack.pypython
import sys

def level_three():
    frame = sys._getframe()
    names = []
    while frame is not None:
        names.append(frame.f_code.co_name)
        frame = frame.f_back
    return names

def level_two():
    return level_three()

def level_one():
    return level_two()

print(level_one())
4
sys._getframe() gets level_three's OWN frame — the top of the stack at this point.
6
frame.f_back walks one level down the stack, toward the caller.
8
The loop continues until f_back is None — the outermost frame, <module>.
Output
['level_three', 'level_two', 'level_one', '<module>']

Why this works: Each function call pushed its own frame onto the stack, and each frame's f_back links to the frame of whoever called it — level_three's frame links back to level_two's, which links back to level_one's, which links back to <module>, the top-level script frame. Walking f_back repeatedly is exactly how a traceback is built when an exception propagates: it lists every frame from where the error occurred back to where the program started.

Assuming a deeply recursive function can recurse indefinitely

Wrong

python
def count_up(n):
    if n <= 0:
        return 0
    return 1 + count_up(n - 1)

print(count_up(10_000))   # RecursionError — the stack has a limit

Better

python
def count_up_iterative(n):
    total = 0
    while n > 0:
        total += 1
        n -= 1
    return total

print(count_up_iterative(10_000))   # no frame growth — one frame, the whole time

What you see: RecursionError: maximum recursion depth exceeded — the call stack has a fixed default limit (sys.getrecursionlimit(), typically 1000), and each recursive call pushes another frame.

Why: Every recursive call to count_up pushes a new frame that stays on the stack until its return value comes back — the stack literally grows one frame per level of recursion. Python enforces a recursion limit specifically to avoid crashing the process by exhausting the real memory (or platform stack) backing the frame stack. An iterative version reuses one frame the whole time, so it has no such limit tied to n.

The call stack, three calls deep

level_three()

top of the stack — currently executing

level_two()

waiting for level_three() to return

level_one()

waiting for level_two() to return

<module>

the outermost frame — where the program started

  1. level_three() — top of the stack — currently executing
  2. level_two() — waiting for level_three() to return
  3. level_one() — waiting for level_two() to return
  4. <module> — the outermost frame — where the program started

What a frame object holds

What a frame object holds
AttributeHolds
f_localsthis call's local variables, as a dict-like view
f_globalsthe module-level namespace visible from this call
f_linenothe line currently executing in this frame
f_backa reference to the CALLER's frame — None for the outermost frame
f_codethe code object this frame is executing (see bytecode)

Together

python
import sys
def f():
    x = 1
    frame = sys._getframe()
    print(frame.f_lineno, sorted(frame.f_locals))
    print(frame.f_back.f_code.co_name)   # the caller's function name
f()

Remember: A frame holds one call's locals, line, and a link (f_back) to its caller. The stack grows per call, shrinks per return — a traceback is that chain.

See also: function calls · local and global namespaces · stack vs heap

Local and global namespaces

standardintermediate

A namespace is a mapping from names to objects. globals() returns the REAL, live module namespace — editing it changes real globals. locals() returns a SNAPSHOT inside a function — editing it usually has no effect.

Think of it as

globals() hands you the actual filing cabinet — move a folder in it, and the cabinet itself changes. locals(), inside a function, hands you a photocopy of what is in the drawer right now — useful to read, but writing on the photocopy does not refile anything in the real drawer.

python
globals()   # the real, live module namespace — a dict you can safely mutate
locals()     # inside a function: a snapshot dict; at module level: same as globals()
vars(obj)    # an object's own namespace, usually obj.__dict__

What we're doing: Mutate globals() and see a real variable change, then attempt the same trick with locals() inside a function and see it fail.

namespaces.pypython
counter = 0
print(counter)
globals()["counter"] = 5
print(counter)


def demo():
    x = 1
    print(x)
    locals()["x"] = 99
    print(x)


demo()
print("counter is module-level:", "counter" in globals())
3
globals()['counter'] = 5 edits the REAL module namespace — counter is that dict entry.
4
The change is visible immediately — 5.
10
locals()['x'] = 99 edits a SNAPSHOT dict, not x itself.
11
x is still 1 — the edit to the locals() snapshot never reached the real local variable.
Output
0
5
1
1
counter is module-level: True

Why this works: At module level, a name like counter genuinely lives as an entry in the module's namespace dict, and globals() returns that exact dict — mutating it is mutating the real thing. Inside a function, CPython optimizes local-variable access to not use a dict at all internally (array slots, resolved at compile time) for speed — locals() builds a dict SNAPSHOT of those slots on the fly, for introspection. Writing to that snapshot has no path back to the real slots, so x stays 1 even after locals()['x'] = 99 appears to set it to 99.

Trying to create a variable dynamically by writing into locals()

Wrong

python
def build_config():
    locals()["debug"] = True
    print(debug)   # NameError — the write never created a real local

Better

python
def build_config():
    config = {}
    config["debug"] = True   # use a real dict when names need to be dynamic
    print(config["debug"])

What you see: NameError: name 'debug' is not defined — even though locals()['debug'] = True appears to have set it, right above.

Why: Python determines a function's set of local variable NAMES at compile time, by scanning the function body for assignments — it does not consult locals() to decide what names exist. Writing to the locals() snapshot dict has no effect on that fixed set of slots, so debug was never actually created as a real local variable. A genuine dict, built and used directly, is the correct tool whenever variable names need to be dynamic.

globals() vs locals() inside a function

globals() vs locals() inside a function
Propertyglobals()locals() (inside a function)
What it returnsthe real module namespacea snapshot dict, rebuilt each call
Editing itchanges real global variablesnot reliably reflected back
Where it appliesmodule-level names, everywherethe CURRENT function call's local names
At module level (no function)same dict as locals()same dict as globals()

Together

python
counter = 0
globals()["counter"] = 5   # a real edit — counter is now 5
print(counter)

def f():
    x = 1
    locals()["x"] = 99      # does NOT reliably change x
    print(x)
f()

Remember: globals() is the real, live module namespace — editing it works. locals() inside a function is a snapshot — editing it rarely changes real locals.

See also: scope and legb · global · call stack and frames

Advertisement

Closures and late binding

How a nested function keeps access to an enclosing variable, the compile-time classification that decides which names those are, and why a function's names are resolved when it runs, not when it is defined.

Closures (the cell mechanism)

standardadvanced

CPython implements a closure using cell objects — small boxes holding one shared value. When a nested function references an enclosing variable, both functions share the SAME cell, so a change made through one is visible through the other.

Think of it as

A cell is a locked box with exactly one slot, shared by however many functions need to see the same enclosing variable. The functions do not each get their own copy of the value — they each get a key to the SAME box, so opening it (reading) or swapping its contents (nonlocal reassignment) is visible to every key-holder.

python
func.__closure__               # tuple of cell objects, or None if func captures nothing
func.__closure__[0].cell_contents   # the value inside the first captured variable's cell
func.__code__.co_freevars           # names of the captured variables, matching __closure__'s order

What we're doing: Inspect the cell objects behind two closures from different calls, and two nested functions sharing one cell from the SAME call.

closure_cells.pypython
def make_counter():
    count = 0
    def increment():
        nonlocal count
        count += 1
        return count
    def read():
        return count
    return increment, read


inc_a, read_a = make_counter()
inc_b, read_b = make_counter()

print(inc_a.__closure__[0] is read_a.__closure__[0])
print(inc_a.__closure__[0] is inc_b.__closure__[0])

inc_a()
inc_a()
print(read_a())
print(read_b())
12
inc_a and read_a come from the SAME call to make_counter, so they share one cell for count.
13
inc_b and read_b come from a SEPARATE call — a completely different cell, holding a separate count.
15
increment and read from the same call share the exact same cell object.
16
Two different calls to make_counter never share a cell — each gets its own.
21
read_a() sees the updates inc_a() made, because both were built from the same call and share one cell.
Output
True
False
2
0

Why this works: increment and read are both defined inside the SAME call to make_counter, so both reference the same count variable — CPython gives them both a reference to the exact same cell object, which is why inc_a.__closure__[0] is read_a.__closure__[0] is True. inc_b and read_b come from an entirely separate call to make_counter, which creates its own fresh count and its own fresh cell — inc_a's cell and inc_b's cell are unrelated, which is exactly why calling inc_a() twice leaves read_b() reporting 0, untouched.

Assuming every nested function gets its own private copy of a captured variable

Wrong

python
def make_pair():
    shared = []
    def add(item):
        shared.append(item)
    def get_all():
        return shared
    return add, get_all

add, get_all = make_pair()
add("x")
print(get_all())   # assumed [] (a private, untouched copy) — it is not

Better

python
# Correct expectation: add and get_all share the SAME cell for
# 'shared' — mutating the list through add() is visible through
# get_all(), because both reference the one list via one shared cell.
def make_pair():
    shared = []
    def add(item):
        shared.append(item)
    def get_all():
        return shared
    return add, get_all

add, get_all = make_pair()
add("x")
print(get_all())   # ['x'] — exactly as the shared-cell mechanism predicts

What you see: There is no bug — the surprise is expecting get_all() to be isolated from add(), when the whole point of sharing a cell is that they are not.

Why: Both add and get_all are defined in the same call to make_pair, referencing the same enclosing name shared — CPython gives them both a reference to one cell holding one list. This is precisely the mechanism that makes a function factory able to return several coordinated functions that operate on shared, private state — get_all seeing add's changes is the feature, not a leak.

Inspecting a closure from the outside

Inspecting a closure from the outside
ExpressionShows
func.__code__.co_freevarsthe names of variables this function captures, e.g. ('n',)
func.__closure__a tuple of cell objects, one per captured name, same order
func.__closure__[0].cell_contentsthe CURRENT value stored in that cell
two closures' __closure__[0] is the same cell?True only if both came from the SAME enclosing call

Together

python
def make_adder(n):
    def add_n(x):
        return x + n
    return add_n

add5 = make_adder(5)
print(add5.__code__.co_freevars)          # ('n',)
print(add5.__closure__[0].cell_contents)   # 5

Remember: A closure uses cell objects — func.__closure__ holds one per captured variable. Same enclosing call shares a cell; separate calls each get their own.

See also: closures · free variables · late binding

Free variables

standardadvanced

A free variable is a name a function uses but does not define itself — it comes from an enclosing scope. Python decides which names are free at COMPILE time, listing them in co_freevars before the function ever runs.

Think of it as

Reading a recipe that says 'add the sauce from the fridge' — the recipe itself never says how to MAKE the sauce, it just references something prepared elsewhere. 'sauce' is free in the recipe: used, but not defined, by the recipe. Python spots every such reference by reading the function's code once, before ever running it.

python
def outer():
    x = 1
    def inner():
        return x   # x is free in inner — read but not assigned here, found in outer
    return inner

inner_func = outer()
inner_func.__code__.co_freevars   # ('x',)

What we're doing: Compare a name that is genuinely free against one that LOOKS captured but is actually local, because it is assigned somewhere in the function body.

free_vars.pypython
def outer():
    value = "captured"

    def reads_only():
        return value   # value is read, never assigned here -> free

    def reassigns():
        value = "local instead"   # assignment anywhere makes it LOCAL for the whole body
        return value

    return reads_only, reassigns


reads_only, reassigns = outer()
print(reads_only.__code__.co_freevars)
print(reassigns.__code__.co_freevars)
4
reads_only only ever READS value, and never assigns it — Python classifies it as free.
8
reassigns ASSIGNS value on this line — that single assignment makes value LOCAL for the entire function body.
16
reads_only's co_freevars correctly lists value.
17
reassigns's co_freevars is empty — value was classified as local, not free, at compile time.
Output
('value',)
()

Why this works: Python decides whether a name is local or free by scanning the ENTIRE function body once, at compile time, looking for any assignment to that name anywhere in it — not by tracing execution order. reads_only never assigns value, so it is classified free and resolved from outer's enclosing scope. reassigns DOES assign value (even though the return statement comes after), which makes value local to reassigns for its whole body — the same all-or-nothing rule scope-and-legb.js describes, just visible here directly in co_freevars.

Assuming a name is free just because it's read before any assignment in the function

Wrong

python
def make_logger():
    level = "INFO"
    def log(message):
        print(level, message)   # looks like it reads the free 'level'...
        level = "DEBUG"          # ...but this assignment changes the classification for the WHOLE function
    return log

logger = make_logger()
logger("started")   # UnboundLocalError, not the expected "INFO started"

Better

python
def make_logger():
    level = "INFO"
    def log(message):
        nonlocal level   # declares level as free (from the enclosing scope), not local
        print(level, message)
        level = "DEBUG"
    return log

logger = make_logger()
logger("started")   # INFO started

What you see: UnboundLocalError: cannot access local variable 'level' where it is not associated with a value — raised on the print() line, even though print() only reads level, before the assignment below it ever runs.

Why: Python classifies level as LOCAL to log the moment it sees ANY assignment to level anywhere in log's body — including the one below the print() call. That classification applies to the whole function, not just after the assignment line executes, so the print() call tries to read a local variable that has not been assigned yet. nonlocal level overrides that classification, telling Python explicitly that level refers to the enclosing free variable instead.

Where a name ends up, compile-time classification

Where a name ends up, compile-time classification
Name is...Classified as
assigned anywhere in this functionlocal — even if read before the assignment (see late binding's cousin bug)
read only, and exists in an enclosing FUNCTIONfree — appears in co_freevars
read only, and exists at module levelglobal — not a free variable
read only, and not found anywherelooked up in builtins at runtime, or NameError

Together

python
def outer():
    value = "captured"
    def inner():
        return value       # free — 'value' is read, never assigned, in an enclosing function
    return inner

f = outer()
print(f.__code__.co_freevars)   # ('value',)

Remember: A free variable is read (never assigned) in a function, found in an enclosing scope, classified at compile time. Any assignment makes it local instead.

See also: closures · scope and legb · nonlocal

Late binding

coreadvanced

Python looks up a name inside a function body at CALL time, not definition time. A function defined once can behave differently each call if a name it references has changed by the time it runs.

Think of it as

A sticky note that says 'call whoever is listed as manager' rather than a note with a specific name written on it. Reading the note does nothing — dialing the number happens only when the call is placed, and by then, whoever the CURRENT manager is gets the call, even if that changed after the note was written.

python
def outer():
    return helper()   # 'helper' is looked up when outer() is CALLED

def helper():
    return "v1"

outer()   # "v1"
def helper():   # redefining the global name
    return "v2"
outer()   # "v2" — outer() sees whichever helper the name currently points to

What we're doing: Call the same function twice, redefining a global it references in between, and confirm each call sees the CURRENT value — not the one from when the caller was defined.

late_binding.pypython
def get_greeting():
    return greet()


def greet():
    return "Hello, v1"


print(get_greeting())


def greet():
    return "Hello, v2"


print(get_greeting())
2
get_greeting references greet() — but does not look it up yet. Nothing runs until get_greeting() is CALLED.
9
First call: greet currently points to the "v1" version — that is what runs.
12
Redefining greet at module level rebinds the global name greet to a NEW function object.
16
Second call: get_greeting's body looks up greet AGAIN, fresh — and finds the new, "v2" version.
Output
Hello, v1
Hello, v2

Why this works: get_greeting's body contains a reference to the name greet, but that reference is not resolved when get_greeting itself is defined — it is resolved every time get_greeting() actually RUNS. Between the two calls, greet is reassigned at module level to a different function object, and the second call to get_greeting() sees that new object because it looks the name up fresh, late, at call time. This is the exact mechanism behind the classic loop-closure bug: every closure in a loop resolves its captured variable late too, so all of them see whatever value it holds by the time they are actually called.

The classic case: closures built in a loop, all resolving the loop variable late

Wrong

python
handlers = []
for event_type in ["click", "hover", "scroll"]:
    handlers.append(lambda: print(f"handling {event_type}"))

for h in handlers:
    h()   # every one prints "handling scroll" — the LAST value

Better

python
handlers = []
for event_type in ["click", "hover", "scroll"]:
    handlers.append(lambda event_type=event_type: print(f"handling {event_type}"))

for h in handlers:
    h()   # "handling click", "handling hover", "handling scroll" — each its own

What you see: All three lambdas print "handling scroll" — the loop's FINAL value — instead of each printing the value it seemed to capture on its own iteration.

Why: Every lambda: print(f'handling {event_type}') references event_type late — looked up when the lambda is actually CALLED, not when it was created. There is only ever one event_type variable, reused by every iteration of the for loop; by the time any lambda runs, the loop has finished and event_type holds its last value, "scroll". A default argument (event_type=event_type) works around this because default values are the one exception to late binding — each is evaluated immediately, at the point that specific lambda is defined, freezing that iteration's value.

Late-bound reference vs early-bound default

Late binding — the norm

  • +Name inside a function body
  • +Looked up fresh, every call
  • +Sees whatever the name currently refers to

Default argument — the exception

  • A def statement's default value
  • Evaluated ONCE, at definition time
  • Never re-evaluated on later calls
  • Late binding — the norm
    • Name inside a function body
    • Looked up fresh, every call
    • Sees whatever the name currently refers to
  • Default argument — the exception
    • A def statement's default value
    • Evaluated ONCE, at definition time
    • Never re-evaluated on later calls

Late-bound vs the one early-bound exception

Late-bound vs the one early-bound exception
WhatWhen resolved
A name referenced inside a function bodyat CALL time — every single call, freshly
A global function called by name from inside another functionat call time — whichever function that name currently points to
A closure variable (free variable)at call time — its cell's CURRENT value
A default argument value (def f(x=expr))at DEFINITION time — evaluated once, the exception to the rule

Together

python
def greet():
    return say_hi()   # 'say_hi' is resolved when greet() RUNS, not when greet is defined

def say_hi():
    return "hi"

print(greet())   # works — say_hi already exists by the time greet() is called

Remember: A function body's names are resolved when it RUNS, not when defined — every call looks them up fresh. Default argument values are the one exception.

See also: closures · closures · free variables

Advertisement

Bytecode and the interpreter

What Python source actually becomes before it runs, and the reference implementation that runs it — conceptual understanding only, per the roadmap.

Bytecode and CPython (conceptual understanding)

standardadvanced

Python source compiles into bytecode — a lower-level instruction set — before it runs. CPython, the reference implementation most people mean by 'Python', executes it on a stack-based virtual machine. Bytecode is not stable across versions.

Think of it as

Source code is a recipe written in English; bytecode is the same recipe translated into a short list of numbered kitchen-station instructions a specific kitchen (CPython's virtual machine) knows how to run quickly. A different kitchen (a different Python implementation, like PyPy) might translate the same recipe into a completely different instruction list — the English recipe is the only thing guaranteed to look the same everywhere.

python
import dis
dis.dis(some_function)     # shows the bytecode instructions, human-readable
some_function.__code__      # the compiled code object itself

What we're doing: Compile a small function and inspect its bytecode with dis.dis(), to see that source code genuinely becomes a lower-level instruction list before it runs.

bytecode.pypython
import dis

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

dis.dis(add)
4
dis.dis() disassembles the compiled function, printing its bytecode instructions rather than running it.
Output
  3           RESUME                   0

  4           LOAD_FAST_BORROW_LOAD_FAST_BORROW 1 (a, b)
              BINARY_OP                0 (+)
              RETURN_VALUE

Why this works: add's source — return a + b — is never interpreted directly, character by character. It is first compiled into a short sequence of bytecode instructions (visible here via dis.dis()) that CPython's virtual machine then executes: load the two argument values, apply the + operation, return the result. The exact instruction NAMES shown here are specific to this Python version — the docs explicitly warn bytecode is not guaranteed stable release to release, so code inspecting or generating specific opcodes can break on a version upgrade.

Assuming a specific opcode's name or behavior is stable across Python versions

Wrong

python
# A tool that pattern-matches on a specific opcode NAME appearing in
# dis.dis() output, then breaks silently on the next Python release
# when that opcode is renamed, merged, or split.

Better

python
# Treat bytecode as an internal implementation detail for
# understanding and debugging performance, not something application
# code should generate, parse, or depend on the exact shape of.
import dis
dis.dis(some_function)   # useful for LOOKING, not for building on top of

What you see: A tool built against dis.dis() output for one Python version silently mis-parses or crashes after a Python upgrade, when opcode names or the instructions a given piece of source compiles to have changed.

Why: The Python glossary states directly that bytecodes are not expected to work between different Python virtual machines, nor to be stable between Python releases. CPython 3.11+ has actively changed opcodes release to release for performance (specialization, adaptive instructions) — treating any specific opcode's presence or name as a stable contract is relying on exactly what the documentation says is not guaranteed.

From source to running program

Source code

the .py file you write

Compile

translated into bytecode — an instruction list

Cache

saved to a .pyc file for reuse

Execute

CPython's stack-based virtual machine runs the bytecode

  1. Source code — the .py file you write
  2. Compile — translated into bytecode — an instruction list
  3. Cache — saved to a .pyc file for reuse
  4. Execute — CPython's stack-based virtual machine runs the bytecode

Source code, compiled, executed — the pipeline

Source code, compiled, executed — the pipeline
StageWhat it produces
Source code (.py)human-readable Python
Compilationbytecode — an instruction list for a virtual machine
Cachingbytecode saved to a .pyc file, reused if the source is unchanged
ExecutionCPython's virtual machine runs the bytecode, instruction by instruction

Together

python
import dis

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

dis.dis(add)   # shows the bytecode instructions for add's body

Remember: Source compiles to bytecode, run by CPython's stack-based VM. CPython is the reference implementation — this detail is a CPython fact, not a language guarantee.

See also: call stack and frames · reference counting · function calls

Advertisement