Volume I introduced bytecode.
We learned that CPython does not execute source code directly.
The path is:
source code
-> tokens
-> syntax tree
-> code object
-> bytecode
-> Python Virtual Machine execution
That first explanation was enough for foundations.
Now we go deeper.
Bytecode is the instruction format CPython's evaluation loop executes.
It is not CPU machine code.
It is not the Python language specification.
It is an implementation detail of CPython.
But it is an extremely useful implementation detail to understand.
Bytecode helps explain:
- why functions create code objects
- how local variables are loaded
- how globals and builtins are searched
- how constants are stored
- how function calls are represented
- how attribute access becomes runtime operations
- how control flow jumps
- how exceptions are handled
- why Python versions can change performance
- why introspection tools can inspect execution
Do not study bytecode to memorize every instruction.
Study bytecode to sharpen your runtime model.
The goal is:
see Python source code as operations over objects, names, frames, and runtime protocols
CPython compiles Python source into bytecode.
Bytecode is a sequence of instructions for the Python Virtual Machine.
Example source:
x = 2 + 3At a high level, CPython needs to:
load constants
perform operation or use folded constant
store result in name x
The exact bytecode can vary by Python version.
That warning matters.
Bytecode is not stable across Python versions.
Instructions are added.
Instructions are removed.
Instructions are specialized.
Optimizations change.
Exception handling layout changes.
Function call bytecode changes.
So the right attitude is:
understand the model, not a frozen list of opcodes
Python's dis module disassembles bytecode.
Example:
import dis
def add(a, b):
return a + b
dis.dis(add)The output depends on your Python version.
It may look roughly like:
LOAD_FAST
LOAD_FAST
BINARY_OP
RETURN_VALUE
The exact formatting and instruction names can differ.
dis lets you inspect:
- functions
- methods
- code objects
- classes
- source snippets compiled with
compile()
Example with a code object:
code = compile("x = 1 + 2", "<string>", "exec")
dis.dis(code)dis is a learning tool, debugging tool, and introspection tool.
It is not something you use in ordinary application logic.
A code object represents compiled executable Python code.
Functions contain code objects.
Example:
def greet(name):
return f"hello {name}"
print(greet.__code__)The function object is not the same as the code object.
The function object contains:
- the code object
- global namespace reference
- default arguments
- closure cells
- annotations
- function name
- other metadata
The code object contains compiled information:
- bytecode
- constants
- variable names
- free variable names
- filename
- first line number
- flags
- exception table information
The relationship:
function object
-> code object
-> bytecode instructions
This is why two functions can have similar source but different runtime metadata.
Code objects expose attributes.
Example:
def add(a, b):
total = a + b
return total
code = add.__code__
print(code.co_name)
print(code.co_varnames)
print(code.co_consts)
print(code.co_names)Possible output:
add
('a', 'b', 'total')
(None,)
()
co_varnames includes local variable names.
co_consts includes constants used by the code object.
co_names includes names referenced by some instructions, often globals or attributes.
Example:
def shout(text):
return text.upper()The name upper may appear in co_names because the bytecode performs attribute lookup.
The exact details vary, but the idea is stable:
code objects store the metadata bytecode needs during execution
Constants used by a code object live in co_consts.
Example:
def example():
return "hello", 42, None
print(example.__code__.co_consts)You may see:
(None, 'hello', 42)The compiler stores constants in the code object.
Bytecode instructions can refer to constants by index.
Conceptually:
LOAD_CONST 1 -> load 'hello'
LOAD_CONST 2 -> load 42
This is not unlike a function carrying a small table of values its instructions need.
Constants are part of compiled code metadata.
They are not looked up by name each time.
Code objects also store names.
Example:
value = 10
def read_global():
return valueThe name value is not a local variable inside read_global.
It is referenced as a global name.
The code object may include it in co_names.
At runtime, the frame uses that name to search:
globals
builtins
This connects back to Volume I's namespaces chapter.
Bytecode does not contain the object value refers to.
It contains the instruction to load the name.
The actual object is found at runtime.
That is why rebinding a global can change what a function sees:
value = 10
def read_global():
return value
value = 99
print(read_global())The function returns 99.
The bytecode loads the name at runtime.
Local variables are handled differently from globals.
Example:
def compute(a, b):
total = a + b
return totalNames a, b, and total are local to the function.
The compiler can assign them local variable slots.
Bytecode may use instructions such as:
LOAD_FAST
STORE_FAST
These are faster than dictionary-style name lookup.
Why?
Because local variable names in a function are known at compile time.
CPython can store them in an array-like structure inside the frame.
Conceptual model:
local slot 0 -> a
local slot 1 -> b
local slot 2 -> total
This is why local variable access is generally faster than global lookup.
The compiler can optimize locals because their scope is known.
Global lookup is more dynamic.
Example:
def length(value):
return len(value)len is not local.
Python looks it up as a global name, then in builtins if not found globally.
Conceptually:
look in function globals for len
if missing, look in builtins for len
call the object found
This is why shadowing builtins can change behavior:
len = 10
def length(value):
return len(value)Now len refers to an integer in globals.
Calling it fails.
Bytecode follows name lookup rules.
It does not know that you meant the built-in len.
Names are resolved at runtime according to namespaces.
A frame represents an executing code object.
When a function is called, CPython creates a frame for that call.
The frame contains:
- the code object being executed
- local variables
- global namespace
- builtins
- instruction pointer
- evaluation stack
- exception state
- links needed for tracing/debugging
Conceptually:
function call
-> new frame
-> executes code object's bytecode
If a function calls another function, another frame is created.
This forms the call stack.
Volume I explained frames conceptually.
Here we connect frames to bytecode:
bytecode is executed inside frames
frames hold the runtime state needed by bytecode
CPython bytecode is stack-based.
Many instructions push values onto an evaluation stack or pop values from it.
Example expression:
a + bConceptually:
load a -> push value of a
load b -> push value of b
add -> pop two values, push result
The evaluation stack is not the same as the call stack.
Call stack:
which functions are currently active
Evaluation stack:
temporary values inside one executing frame
This distinction is important.
Each frame has its own evaluation state.
Nested function calls create new frames.
Expressions inside a frame use that frame's evaluation stack.
Example:
import dis
def compute(a, b):
total = a + b
return total
dis.dis(compute)You might see instructions conceptually like:
RESUME
LOAD_FAST a
LOAD_FAST b
BINARY_OP +
STORE_FAST total
LOAD_FAST total
RETURN_VALUE
Do not worry if your output differs.
The broad flow is:
start frame
load local a
load local b
perform addition
store local total
load local total
return it
Bytecode makes explicit what source code hides.
Source code:
total = a + bBytecode model:
load values
operate
store result
Function calls are more involved than they appear.
Source:
result = func(a, b)At runtime, Python must:
- load the callable object
- load arguments
- arrange the call
- invoke the callable protocol
- receive the return value
- store the result
Conceptual bytecode flow:
load func
load a
load b
call
store result
Function calls create frames when the callable is a Python function.
But not every callable creates a Python frame in the same way.
Built-in functions and C extension functions may execute in native code.
Callable objects use __call__.
Methods involve descriptor binding before the call.
The bytecode instruction says "call this callable."
The runtime then follows Python's callable machinery.
Method calls combine attribute access and function calling.
Source:
text.upper()The runtime must:
- load
text - find attribute
upper - bind the method if needed
- call it
- return the result
This connects to descriptors.
Functions stored on classes are descriptors.
When accessed through an instance, they produce bound methods.
Bytecode has evolved over Python versions to optimize method calls.
The exact instruction names may change.
The conceptual flow remains:
object -> attribute lookup -> callable -> call
Method calls are not primitive magic.
They are runtime operations over objects and types.
Source:
value = obj.nameThe bytecode performs attribute access.
Conceptually:
load obj
load attribute name
store result in value
Attribute access may involve:
- instance dictionary
- class dictionary
- descriptors
- properties
__getattribute____getattr__- MRO search
- slots
The bytecode instruction does not contain all of that logic.
It delegates to runtime attribute lookup.
This is the pattern:
bytecode selects an operation
runtime object model performs the operation
That is why earlier chapters on descriptors, properties, MRO, and __slots__ matter.
Bytecode triggers those mechanisms.
It does not replace them.
Conditionals and loops compile into jumps.
Example:
if value:
result = "yes"
else:
result = "no"Conceptually:
load value
test truthiness
jump if false to else block
load "yes"
store result
jump past else block
load "no"
store result
Loops also use jumps.
Example:
for item in items:
handle(item)Conceptually:
get iterator from items
get next item
if iterator exhausted, exit loop
store item
call handle(item)
jump back to get next item
This connects directly to the iterator protocol.
The bytecode manages control flow.
The object model provides iteration behavior.
for loops are not special to lists.
Source:
for item in iterable:
process(item)Runtime model:
iterator = iter(iterable)
repeat:
item = next(iterator)
process(item)
stop when StopIteration occurs
Bytecode uses instructions that implement this loop pattern.
When the iterator is exhausted, the loop exits.
This is why any object that implements the iterator protocol can work in a for loop.
Bytecode does not care whether the object is a list, tuple, generator, file object, or custom iterator.
It asks for iteration behavior at runtime.
Again:
syntax -> bytecode operation -> runtime protocol
Exception handling also compiles into bytecode and metadata.
Source:
try:
risky()
except ValueError:
recover()The runtime must know:
- where protected code begins
- where handlers live
- which exceptions match
- where to continue after handling
- how to unwind stack state
Modern CPython uses exception table information associated with code objects.
The exact representation has changed across versions.
The conceptual model:
execute protected block
if exception occurs, consult handler metadata
match exception type
run handler or propagate
Exception handling is not just a high-level idea.
It is represented in compiled code metadata and runtime frame state.
This is one reason traceback information can point to precise source locations.
Code objects contain information that maps bytecode back to source locations.
This supports:
- tracebacks
- debuggers
- coverage tools
- profilers
- tracing hooks
When an exception occurs, Python can show:
file name
line number
function name
source line
That information comes from the relationship between:
- code objects
- frames
- line number tables
- traceback objects
This is why tracebacks are more than error strings.
They are structured runtime information about frame execution.
Modern CPython can specialize bytecode at runtime.
This means instructions may adapt based on the types and operations actually seen during execution.
For example, loading an attribute from a common object shape may become optimized internally.
This is part of CPython's adaptive interpreter work.
The important idea:
bytecode you see may include adaptive behavior or cache entries depending on options and Python version
The dis module has options to show more detail in some versions.
Do not panic when disassembly includes instructions or cache information you did not expect.
The stable lesson is:
CPython can optimize common runtime patterns while preserving Python semantics
The implementation changes.
The language behavior remains the contract.
Bytecode can help explain performance, but it should not be your only performance tool.
Example:
def use_local(value):
local_len = len
return local_len(value)Older advice sometimes suggested binding globals to locals because local lookup can be faster.
Modern CPython optimizations can change whether such micro-optimizations matter.
The lesson:
understand bytecode for insight
measure performance before optimizing
Bytecode can reveal:
- repeated global lookups
- function call overhead
- attribute access
- loop structure
- constant folding
- closure access
But real performance depends on:
- algorithm choice
- data structures
- I/O
- allocation behavior
- interpreter version
- C extension behavior
- workload size
Do not optimize from disassembly alone.
Use profiling.
The compiler may simplify some constant expressions.
Example:
def value():
return 2 + 3The compiler may store 5 as a constant rather than emitting runtime addition.
Disassembly might show loading 5.
This is called constant folding.
The compiler can optimize expressions that are safe to compute ahead of time.
But it cannot fold everything.
Example:
def value(x):
return x + 3The value of x is not known at compile time.
The addition must happen at runtime.
The idea:
compile-time known values can sometimes become constants
runtime-dependent values become bytecode operations
Closures require bytecode to access variables from enclosing scopes.
Example:
def make_multiplier(factor):
def multiply(value):
return value * factor
return multiplyfactor is not local to multiply.
It is a free variable captured from the enclosing scope.
Code objects expose closure-related names:
fn = make_multiplier(10)
print(fn.__code__.co_freevars)
print(fn.__closure__)co_freevars tells you which free variable names the code uses.
__closure__ contains cell objects holding captured values.
Bytecode uses special mechanisms to load from closure cells.
This connects Volume I closures to runtime internals:
closures are not magic memory
they are code objects plus cell references
Comprehensions can create their own hidden function-like code objects.
Example:
def squares(values):
return [value * value for value in values]If you inspect constants:
print(squares.__code__.co_consts)you may see a nested code object for the list comprehension.
This helps explain why comprehension loop variables do not leak into the surrounding scope in modern Python.
The comprehension has its own scope-like execution context.
Again, exact details vary.
The important idea:
some syntax creates hidden compiled code objects
Functions are not the only source of code objects.
Generator functions compile differently from ordinary functions.
Example:
def numbers():
yield 1
yield 2Calling numbers() returns a generator object.
The function body does not run immediately.
The generator object holds execution state.
Each next() resumes the frame until the next yield.
Bytecode for generators includes yield-related instructions.
The conceptual difference:
ordinary function:
call -> run to return or exception
generator function:
call -> create generator object
next -> run until yield
next -> resume after yield
The generator chapter explained the behavior.
Bytecode internals show that the behavior is represented in compiled execution machinery.
Async functions also compile into special code objects.
Example:
async def fetch():
await operation()Calling fetch() returns a coroutine object.
The body does not run immediately.
The coroutine is driven by an event loop or by awaiting it.
Bytecode contains await-related operations.
Conceptually:
async function call -> coroutine object
await -> suspend until awaitable completes
resume -> continue execution
This connects Chapter 67 to internals.
Async behavior is not a separate universe.
It is another form of compiled code object plus runtime execution state.
This point deserves repetition.
Bytecode is an implementation detail.
Do not write normal application code that depends on exact bytecode instruction sequences.
Different Python versions can change:
- instruction names
- instruction arguments
- stack effects
- call conventions
- exception handling representation
- line number tables
- specialization behavior
- optimization choices
Code that inspects bytecode must be version-aware.
Examples:
- debuggers
- profilers
- coverage tools
- linters
- educational tools
- advanced instrumentation
For ordinary applications, bytecode is for understanding, not dependency.
The Python language behavior is the contract.
The bytecode is CPython's current strategy.
If bytecode is unstable, why learn it?
Because it clarifies the execution model.
Bytecode helps you understand:
- why function bodies do not run at definition time
- why local variables are different from globals
- why closures need cells
- why calls have overhead
- why loops depend on iterator protocol
- why comprehensions have their own scope
- why tracebacks know line numbers
- why interpreter versions can change performance
- why Python can introspect execution
Bytecode is like an X-ray.
You do not build the whole house out of X-rays.
But when you need to understand the structure underneath, it is invaluable.
Do not treat bytecode as Python source code.
It is compiled instruction data for CPython's virtual machine.
Do not assume bytecode is CPU machine code.
The CPU does not directly execute Python bytecode.
Do not memorize bytecode instruction lists as if they were stable language syntax.
They change across versions.
Do not assume disassembly from one Python version applies exactly to another.
Check the version.
Do not use bytecode inspection instead of profiling for performance decisions.
It can inform profiling, not replace it.
Do not assume one source line maps to one bytecode instruction.
One line may compile into many instructions, and some instructions may not correspond neatly to a visible line.
Do not forget that bytecode delegates to runtime protocols.
LOAD_ATTR or equivalent attribute access still invokes Python's attribute machinery.
Do not forget that code objects are not function objects.
A function object contains a code object plus runtime context and metadata.
When inspecting bytecode, ask:
- Which Python version produced this output?
- Am I looking at a function, code object, class, or expression?
- Which names are local?
- Which names are global or builtins?
- Which constants are stored in the code object?
- Where are function calls?
- Where are attribute lookups?
- Where are jumps?
- Does this code create nested code objects?
- Are closures involved?
- Are generators or async functions involved?
- Am I learning a model or depending on an implementation detail?
Bytecode inspection is most valuable when guided by a question.
Do not stare at disassembly hoping meaning appears.
Ask what source behavior you are trying to explain.
-
Use
dis.dis()on a function that returnsa + b. Identify local loads, the binary operation, and return. -
Inspect
__code__.co_varnames,co_consts, andco_namesfor a simple function. -
Compare bytecode for a function using a local variable and a global variable.
-
Disassemble a function that calls
len(value). Identify where the namelenappears. -
Disassemble a method call such as
text.upper(). Explain the attribute lookup and call conceptually. -
Disassemble an
ifstatement and identify jump instructions. -
Disassemble a
forloop and connect it to the iterator protocol. -
Inspect a closure's
co_freevarsand__closure__. -
Inspect
co_constsfor a function containing a list comprehension. -
Disassemble a generator function and compare it with an ordinary function.
-
Disassemble an async function and identify that the output differs from ordinary functions.
-
Try the same disassembly on two Python versions if available. Note differences.
-
Find an example of constant folding by disassembling a function returning a constant expression.
-
Explain why relying on exact bytecode sequences is risky.
CPython compiles Python source code into code objects containing bytecode.
Bytecode is an instruction format for the Python Virtual Machine, not CPU machine code.
The dis module disassembles bytecode for inspection.
Function objects contain code objects.
Code objects store bytecode, constants, names, variable names, flags, source metadata, and other execution information.
Frames execute code objects.
Frames hold local variables, global and builtin namespace references, instruction state, and an evaluation stack.
Local variables can be accessed through optimized local slots.
Global names are resolved through global and builtin namespaces at runtime.
Function calls load callables and arguments, invoke callable behavior, and may create new frames.
Method calls combine attribute lookup, descriptor behavior, and calling.
Attribute access delegates to Python's runtime object model.
Conditionals and loops compile into jumps.
for loops compile into iterator protocol operations.
Exception handling uses bytecode plus code-object metadata such as exception tables.
Closures use free variable metadata and cell objects.
Comprehensions can create nested code objects.
Generators and async functions compile into special resumable execution forms.
Bytecode changes across Python versions and should not be treated as stable application API.
The deep lesson is:
bytecode reveals how source syntax becomes runtime operations over frames, names, objects, and protocols
Once you see that, Python's execution model becomes less magical and more mechanical.
Chapter 69 studied bytecode internals.
We saw how code objects, frames, instructions, constants, names, jumps, calls, closures, generators, async functions, and exception metadata fit together.
Next we zoom out to CPython architecture.
Chapter 70 will study the implementation that makes these pieces work:
- CPython as the reference implementation
- parser and compiler pipeline
- code objects
- frame evaluation
- object representation
- reference counting
- garbage collection
- the GIL
- memory allocation
- interpreter state
- module initialization
- extension boundaries
The transition is:
bytecode explains the instruction stream
CPython architecture explains the machine that executes it
The next chapter connects the execution model to CPython's larger runtime design.