Python Internals Deep Dive
CPython's implementation, bytecode, execution model, and GIL mechanics.
The Internal Behavior
Understanding Python's internals transforms you from a Python user to a Python expert. This lesson explores how CPython (the reference implementation) actually works under the hood, from source code to bytecode execution, the notorious GIL, import mechanisms, and AST manipulation.
Why Learn Python Internals?
- Write more efficient code by understanding execution costs
- Debug complex issues with confidence
- Make informed architecture decisions (threading vs multiprocessing)
- Contribute to CPython or understand C extension APIs
- Master metaprogramming with AST manipulation
1. Python Bytecode & the dis Module
Python doesn't directly execute your source code. Instead, it compiles it to bytecode, a low-level, platform-independent instruction set for the Python virtual machine (PVM).
What is Bytecode?
Bytecode is stored in .pyc files (in __pycache__ directories). When you run a Python script, CPython:
- Parses source code into an Abstract Syntax Tree (AST)
- Compiles AST to bytecode
- Executes bytecode in the Python Virtual Machine
From source to execution
Figure 1: CPython never runs your text directly - it parses to an AST, compiles that to bytecode (cached in __pycache__), then the Python Virtual Machine executes the bytecode.
Using the dis Module
The dis module disassembles Python bytecode, revealing what the interpreter actually executes.
Simple Function Disassembly
import dis
def add_numbers(a, b):
return a + b
# Disassemble the function
dis.dis(add_numbers)Expected Output:
3 0 RESUME 0
4 2 LOAD_FAST 0 (a)
4 LOAD_FAST 1 (b)
6 BINARY_OP 0 (+)
10 RETURN_VALUEUnderstanding Bytecode Instructions
- RESUME 0: No-op marker the interpreter uses to (re)enter the frame
- LOAD_FAST 0 (a): Load local variable 'a' onto the stack
- LOAD_FAST 1 (b): Load local variable 'b' onto the stack
- BINARY_OP 0 (+): Pop two values, apply the operator, push result
- RETURN_VALUE: Return top of stack to caller
Bytecode is version-specific
Exact instructions change between Python releases, this is why the dis docs call bytecode an implementation detail. The output above is from CPython 3.12. On 3.10 and earlier the same function compiled to four instructions with no RESUME, and used the specialized BINARY_ADD rather than the generic BINARY_OP with an operator argument; what is nowCALL was CALL_FUNCTION. Always run dis on your own interpreter to see what it actually emits.
More Complex Example: Loop Optimization
Let's see how Python compiles different loop approaches:
import dis
# Approach 1: Using append in loop
def sum_with_append():
result = []
for i in range(5):
result.append(i * 2)
return result
print("=== Approach 1: append in loop ===")
dis.dis(sum_with_append)Expected Output:
=== Approach 1: append in loop ===
4 0 RESUME 0
5 2 BUILD_LIST 0
4 STORE_FAST 0 (result)
6 6 LOAD_GLOBAL 1 (NULL + range)
16 LOAD_CONST 1 (5)
18 CALL 1
26 GET_ITER
>> 28 FOR_ITER 22 (to 76)
32 STORE_FAST 1 (i)
7 34 LOAD_FAST 0 (result)
36 LOAD_ATTR 3 (NULL|self + append)
56 LOAD_FAST 1 (i)
58 LOAD_CONST 2 (2)
60 BINARY_OP 5 (*)
64 CALL 1
72 POP_TOP
74 JUMP_BACKWARD 24 (to 28)
6 >> 76 END_FOR
8 78 LOAD_FAST 0 (result)
80 RETURN_VALUELOAD_ATTR to find appendand a CALL to invoke it.import dis
# Approach 2: List comprehension
def sum_with_comprehension():
return [i * 2 for i in range(5)]
print("=== Approach 2: list comprehension ===")
dis.dis(sum_with_comprehension)Expected Output:
=== Approach 2: list comprehension ===
4 0 RESUME 0
5 2 LOAD_GLOBAL 1 (NULL + range)
12 LOAD_CONST 1 (5)
14 CALL 1
22 GET_ITER
24 LOAD_FAST_AND_CLEAR 0 (i)
26 SWAP 2
28 BUILD_LIST 0
30 SWAP 2
>> 32 FOR_ITER 7 (to 50)
36 STORE_FAST 0 (i)
38 LOAD_FAST 0 (i)
40 LOAD_CONST 2 (2)
42 BINARY_OP 5 (*)
46 LIST_APPEND 2
48 JUMP_BACKWARD 9 (to 32)
>> 50 END_FOR
52 SWAP 2
54 STORE_FAST 0 (i)
56 RETURN_VALUE
>> 58 SWAP 2
60 POP_TOP
62 SWAP 2
64 STORE_FAST 0 (i)
66 RERAISE 0
ExceptionTable:
28 to 50 -> 58 [2]LIST_APPEND, which appends straight to the list on the stack with no attribute lookup and no call.Performance Insight
List comprehensions are faster because they use optimized bytecode (LIST_APPEND) instead of attribute lookups (LOAD_ATTR) and method calls. The bytecode reveals why certain patterns are more efficient.
Bytecode for Conditional Logic
import dis
def check_value(x):
if x > 10:
return "large"
else:
return "small"
dis.dis(check_value)Expected Output:
3 0 RESUME 0
4 2 LOAD_FAST 0 (x)
4 LOAD_CONST 1 (10)
6 COMPARE_OP 68 (>)
10 POP_JUMP_IF_FALSE 1 (to 14)
5 12 RETURN_CONST 2 ('large')
7 >> 14 RETURN_CONST 3 ('small')Practical Use: Debugging Closures
import dis
def outer(x):
def inner(y):
return x + y # 'x' is a closure variable
return inner
closure_func = outer(10)
# The cell repr carries memory addresses, so read what it holds instead
print(f"Closure variables: {closure_func.__code__.co_freevars}")
print(f"Captured value: {closure_func.__closure__[0].cell_contents}")
dis.dis(closure_func)Expected Output:
Closure variables: ('x',)
Captured value: 10
0 COPY_FREE_VARS 1
4 2 RESUME 0
5 4 LOAD_DEREF 1 (x)
6 LOAD_FAST 0 (y)
8 BINARY_OP 0 (+)
12 RETURN_VALUE2. Python's Execution Model
Understanding how Python executes code, from source to bytecode to runtime, reveals optimization opportunities and explains behavior that seems "magical."
The Compilation Pipeline
1. Source Code
.py file2. Parse
AST3. Compile
Bytecode4. Execute
PVMCode Objects
Every function, class, and module has an associated code object containing compiled bytecode and metadata.
def example(a, b):
x = a + b
return x * 2
# Access the code object
code = example.__code__
print(f"Function name: {code.co_name}")
print(f"Argument count: {code.co_argcount}")
print(f"Local variables: {code.co_varnames}")
print(f"Bytecode: {code.co_code}")
print(f"Constants: {code.co_consts}")
print(f"Line numbers: {code.co_firstlineno}")Expected Output:
Function name: example
Argument count: 2
Local variables: ('a', 'b', 'x')
Bytecode: b'\x97\x00|\x00|\x01z\x00\x00\x00}\x02|\x02d\x01z\x05\x00\x00S\x00'
Constants: (None, 2)
Line numbers: 1Frame Objects & Execution Stack
Each function call creates a frame object, the runtime context containing local variables, the instruction pointer, and links to other frames.
import inspect
def outer():
x = 10
inner()
def inner():
y = 20
# Inspect the current frame
frame = inspect.currentframe()
print(f"Current function: {frame.f_code.co_name}")
# f_locals holds the frame object itself too, and its repr carries an
# address, so print the names rather than the whole dict
print(f"Local names: {sorted(frame.f_locals)}")
print(f"y is still: {frame.f_locals['y']}")
print(f"Line number: {frame.f_lineno}")
# Walk up the call stack
caller_frame = frame.f_back
print(f"\nCaller function: {caller_frame.f_code.co_name}")
print(f"Caller locals: {caller_frame.f_locals}")
outer()Expected Output:
Current function: inner
Local names: ['frame', 'y']
y is still: 20
Line number: 17
Caller function: outer
Caller locals: {'x': 10}Practical Example: Custom Stack Trace
import inspect
def print_call_stack():
"""Print the call stack manually"""
frame = inspect.currentframe()
print("Call Stack:")
while frame:
code = frame.f_code
# code.co_filename is available too, but it is an absolute path,
# so print the names to keep the output the same on every machine
print(f" {code.co_name} (line {frame.f_lineno})")
frame = frame.f_back
def level3():
print_call_stack()
def level2():
level3()
def level1():
level2()
level1()Expected Output:
Call Stack: print_call_stack (line 12) level3 (line 16) level2 (line 19) level1 (line 22) <module> (line 24)
3. The Global Interpreter Lock (GIL)
The GIL is Python's most controversial feature. Understanding it deeply (building on Lesson 3: Threading) is crucial for writing concurrent Python code.
What is the GIL?
The Global Interpreter Lock is a mutex that protects access to Python objects, preventing multiple threads from executing Python bytecode simultaneously.
GIL Limitation
One thread executes Python bytecode at a time, even on multi-core CPUs. This means CPU-bound multi-threaded Python code won't utilize multiple cores. However, I/O-bound threads work fine because they release the GIL during I/O operations.
Why Does Python Have the GIL?
- Memory Management: CPython's reference counting isn't thread-safe. Without the GIL, every object would need locks, severely impacting single-threaded performance.
- C Extension Compatibility: Many C extensions assume single-threaded execution. The GIL makes writing C extensions simpler and safer.
- Simplicity: Easier interpreter implementation. Removing the GIL requires massive refactoring (attempted multiple times, always resulted in single-threaded slowdown).
How the GIL Works
CPython releases the GIL every few bytecode instructions (or during blocking I/O), allowing other threads to run. This is called GIL switching.
import threading
import time
# Global counter (shared between threads)
counter = 0
def increment():
global counter
for _ in range(20_000):
temp = counter
time.sleep(0) # hands the GIL to the other thread, mid-update
counter = temp + 1
# Run with two threads
threads = [threading.Thread(target=increment) for _ in range(2)]
start = time.perf_counter()
for t in threads:
t.start()
for t in threads:
t.join()
end = time.perf_counter()
print(f"Final counter: {counter}")
print(f"Time: {end - start:.3f}s")
# Without the time.sleep(0) this prints exactly 40000 every run, which is
# misleading. "counter += 1" compiles to three separate bytecodes
# (LOAD_GLOBAL, ADD, STORE_GLOBAL) and a thread switch can land between any
# two of them. The GIL only guarantees that one thread runs bytecode at a
# time; it does not make a multi-step update atomic.Expected Output:
Final counter: 20000 Time: 1.051s
When is the GIL Released?
GIL Released During:
- I/O operations (file, network)
- time.sleep()
- C library calls (if explicitly released)
- NumPy operations (releases GIL internally)
- Database queries
GIL Held During:
- Pure Python computation
- List/dict operations
- String processing
- Most bytecode execution
- Object creation/destruction
GIL Impact Demonstration
import threading
import time
def cpu_bound():
"""CPU-bound task (GIL held)"""
total = 0
for i in range(10_000_000):
total += i
return total
def io_bound():
"""I/O-bound task (GIL released)"""
time.sleep(0.1) # Simulates I/O
return "done"
def timed(fn, n, threaded):
"""Run fn n times, either on n threads or one after another"""
start = time.perf_counter()
if threaded:
threads = [threading.Thread(target=fn) for _ in range(n)]
for t in threads:
t.start()
for t in threads:
t.join()
else:
for _ in range(n):
fn()
return time.perf_counter() - start
for label, fn, n in [("CPU-bound", cpu_bound, 2), ("I/O-bound", io_bound, 10)]:
parallel = timed(fn, n, threaded=True)
serial = timed(fn, n, threaded=False)
print(f"{label}: {parallel:.3f}s threaded vs {serial:.3f}s sequential "
f"-> {serial / parallel:.1f}x")Expected Output:
CPU-bound: 0.337s threaded vs 0.336s sequential -> 1.0x I/O-bound: 0.102s threaded vs 1.001s sequential -> 9.8x
time.sleep releases the GIL and the ten waits overlap. Absolute times depend on your machine, the ratios much less so.GIL in CPython 3.13+: Per-Interpreter GIL
CPython 3.13 introduces per-interpreter GIL (PEP 684), allowing true parallelism using subinterpreters instead of processes.
Future Direction
Each subinterpreter has its own GIL, allowing CPU-bound parallelism without multiprocessing overhead. This is experimental in 3.13 and will mature in future versions. The "no-GIL" Python (PEP 703) is also being explored but remains controversial.
Working Around the GIL
| Approach | Use Case | Pros | Cons |
|---|---|---|---|
| multiprocessing | CPU-bound tasks | True parallelism, separate memory | Higher overhead, no shared memory |
| asyncio | I/O-bound, many connections | Efficient, single-threaded | Requires async/await, different paradigm |
| NumPy/Cython | Numerical computation | Releases GIL, very fast | Limited to specific domains |
| C Extensions | Performance-critical code | Full control, can release GIL | Complex, platform-specific |
4. Import System Internals
Python's import system is more complex than it appears. Understanding it helps you debug import errors, create custom importers, and optimize startup time.
How Imports Work
When you import module, Python:
- Checks
sys.modulescache (if found, return cached module) - Searches
sys.pathfor the module - Uses finders to locate module spec
- Uses loaders to load and execute module
- Caches result in
sys.modules
Exploring sys.modules
import sys
# sys.modules is a dict of every module already imported
print(f"Total modules loaded: {len(sys.modules)}")
# 'os' is a poor example of "not yet imported": the interpreter loads it
# during startup, so it is already in sys.modules before your first line.
print(f"'os' imported: {'os' in sys.modules}")
# Use a module the interpreter does not preload to see the difference:
print(f"'json' imported: {'json' in sys.modules}")
import json
print(f"'json' imported now: {json.__name__ in sys.modules}")
print(f"Total modules loaded: {len(sys.modules)}")
import os
# Get the module object. In 3.11+ 'os' is frozen into the binary, so its
# repr says (frozen) rather than naming a file, but __file__ still resolves.
os_module = sys.modules['os']
print(f"Module object: {os_module}")
print(f"Same object the name 'os' is bound to: {os_module is os}")
print(f"Module file: {os_module.__file__}")Expected Output:
Total modules loaded: 32 'os' imported: True 'json' imported: False 'json' imported now: True Total modules loaded: 55 Module object: <module 'os' (frozen)> Same object the name 'os' is bound to: True Module file: /usr/local/lib/python3.12/os.py
sys.modules makes repeated imports nearly free. The counts above come from a clean CPython 3.12 and depend on the build: a virtualenv interpreter starts one module higher, and the file path follows your install prefix.Understanding sys.path
import sys
# sys.path is a list of directories Python searches for modules, in order
print("Module search paths:")
for path in sys.path[:5]: # Show first 5
print(f" {path}")
# Add a custom path (useful for development)
before = len(sys.path)
sys.path.insert(0, '/my/custom/modules')
# Now Python will search /my/custom/modules first
print(f"First entry: {sys.path[0]}")
print(f"Entries added: {len(sys.path) - before}")Expected Output:
Module search paths: /app /usr/local/lib/python312.zip /usr/local/lib/python3.12 /usr/local/lib/python3.12/lib-dynload /usr/local/lib/python3.12/site-packages First entry: /my/custom/modules Entries added: 1
/appin the run above), then the standard library and finally site-packages. Your paths will differ; the order will not.Import Hooks
Import hooks let you customize the import process, load modules from databases, networks, or apply transformations during import.
Custom Finder and Loader
import sys
from importlib.abc import MetaPathFinder, Loader
from importlib.machinery import ModuleSpec
class CustomLoader(Loader):
"""Loader that generates module code on-the-fly"""
def exec_module(self, module):
# Generate code dynamically
code = """
def generated_function():
return "This module was generated dynamically!"
"""
exec(code, module.__dict__)
class CustomFinder(MetaPathFinder):
"""Finder that intercepts imports for 'dynamic_' prefix"""
def find_spec(self, fullname, path, target=None):
if fullname.startswith("dynamic_"):
return ModuleSpec(
fullname,
CustomLoader(),
origin="custom-importer"
)
return None
# Install the custom finder
sys.meta_path.insert(0, CustomFinder())
# There is no dynamic_module.py anywhere on disk, yet this import resolves
import dynamic_module
print(dynamic_module.generated_function())
print(f"Loaded from: {dynamic_module.__spec__.origin}")Expected Output:
This module was generated dynamically! Loaded from: custom-importer
.py file exists: the finder claims the name and the loader builds the module body at import time.Practical Use: Module Auto-Reload
import importlib
import pathlib
import sys
import tempfile
# Create a real module on disk so the reload below has something to re-read
tmp = pathlib.Path(tempfile.mkdtemp())
(tmp / "my_module.py").write_text("VALUE = 'first version'\n")
sys.path.insert(0, str(tmp))
# Standard import
import my_module
print(my_module.VALUE)
# The file changes on disk...
(tmp / "my_module.py").write_text("VALUE = 'second version'\n")
# ...but importing again just hands back the cached object in sys.modules
import my_module
print(my_module.VALUE)
# reload() re-executes the file and updates that same module object in place
importlib.reload(my_module)
print(my_module.VALUE)
# Useful for development/debugging without restarting PythonExpected Output:
first version first version second version
import does nothing once the module is cached, which is exactly why reload() has to exist.Import Performance Optimization
import importlib
import sys
import time
# Measure import time. asyncio stands in for a heavy third-party library
# here so the snippet runs anywhere: it drags in selectors, socket, ssl
# and about a hundred more modules. import_module is what the plain
# "import asyncio" statement calls into, and it is easier to time.
before = len(sys.modules)
start = time.perf_counter()
importlib.import_module("asyncio")
end = time.perf_counter()
print(f"Import time: {end - start:.3f}s")
print(f"Modules pulled in: {len(sys.modules) - before}")
# Asking for it a second time is a dict lookup in sys.modules, nothing more
start = time.perf_counter()
importlib.import_module("asyncio")
print(f"Second import: {time.perf_counter() - start:.6f}s")
# Lazy imports (defer until needed) keep that cost off your startup path
def process_data():
import pandas as pd # Only imported when the function is called
return pd.DataFrame()Expected Output:
Import time: 0.025s Modules pulled in: 100 Second import: 0.000003s
sys.modules.5. Abstract Syntax Tree (AST) Manipulation
The Abstract Syntax Tree represents Python code structure. Manipulating AST enables metaprogramming: code generation, optimization, and custom language features.
What is an AST?
An AST is a tree representation of source code structure. Each node represents a construct (function, if-statement, expression, etc.).
Inspecting AST
import ast
code = """
def add(a, b):
return a + b
"""
# Parse code into AST
tree = ast.parse(code)
# Pretty print the AST
print(ast.dump(tree, indent=2))Expected Output:
Module(
body=[
FunctionDef(
name='add',
args=arguments(
posonlyargs=[],
args=[
arg(arg='a'),
arg(arg='b')],
kwonlyargs=[],
kw_defaults=[],
defaults=[]),
body=[
Return(
value=BinOp(
left=Name(id='a', ctx=Load()),
op=Add(),
right=Name(id='b', ctx=Load())))],
decorator_list=[],
type_params=[])],
type_ignores=[])Walking the AST
import ast
class FunctionVisitor(ast.NodeVisitor):
"""Find all function definitions"""
def visit_FunctionDef(self, node):
print(f"Found function: {node.name}")
print(f" Arguments: {[arg.arg for arg in node.args.args]}")
print(f" Line: {node.lineno}")
self.generic_visit(node) # Continue visiting child nodes
code = """
def foo(x):
return x * 2
def bar(y, z):
return y + z
"""
tree = ast.parse(code)
visitor = FunctionVisitor()
visitor.visit(tree)Expected Output:
Found function: foo Arguments: ['x'] Line: 2 Found function: bar Arguments: ['y', 'z'] Line: 5
Modifying AST
You can transform code by modifying the AST before compilation.
Example: Adding Print Statements to Functions
import ast
class FunctionDebugger(ast.NodeTransformer):
"""Add print statement at start of every function"""
def visit_FunctionDef(self, node):
# Create a print statement: print(f"Calling {function_name}")
print_stmt = ast.Expr(
value=ast.Call(
func=ast.Name('print', ctx=ast.Load()),
args=[ast.Constant(f"Calling {node.name}")],
keywords=[]
)
)
# Insert print at start of function body
node.body.insert(0, print_stmt)
return node
# Original code
code = """
def greet(name):
return f"Hello, {name}"
def calculate(x):
return x * 2
"""
# Parse, transform, and compile
tree = ast.parse(code)
transformer = FunctionDebugger()
new_tree = transformer.visit(tree)
ast.fix_missing_locations(new_tree) # Required after modification
# Compile and execute
compiled = compile(new_tree, '<ast>', 'exec')
namespace = {}
exec(compiled, namespace)
# The functions now announce themselves, though the source never said so
greet = namespace['greet']
calculate = namespace['calculate']
print(greet("Alice"))
print(calculate(10))Expected Output:
Calling greet Hello, Alice Calling calculate 20
greet nor calculate contains a print in its source, yet both announce themselves: the transformer inserted the call into the tree before it was ever compiled.Generating Code from AST
import ast
# Build AST programmatically
module = ast.Module(
body=[
ast.FunctionDef(
name='multiply',
args=ast.arguments(
args=[ast.arg('x'), ast.arg('y')],
defaults=[]
),
body=[
ast.Return(
value=ast.BinOp(
left=ast.Name('x', ctx=ast.Load()),
op=ast.Mult(),
right=ast.Name('y', ctx=ast.Load())
)
)
],
decorator_list=[]
)
],
type_ignores=[]
)
# Fix locations and compile
ast.fix_missing_locations(module)
code = compile(module, '<generated>', 'exec')
# Execute
namespace = {}
exec(code, namespace)
multiply = namespace['multiply']
print(multiply(5, 3))Expected Output:
15
Security Warning
Using exec() and compile() with untrusted input is dangerous. Only use AST manipulation on code you control. For user input, use safe alternatives like ast.literal_eval().
6. CPython Source Code Tour
Exploring CPython's source code (C implementation) reveals how Python features are implemented at the lowest level.
CPython Repository Structure
| Directory | Contents |
|---|---|
Python/ | Core interpreter: bytecode execution, import system, GIL |
Objects/ | Built-in types: int, str, list, dict implementations |
Include/ | C header files: Python.h, object.h, pystate.h |
Lib/ | Python standard library (.py files) |
Modules/ | C-implemented modules: _io, _json, _socket |
Parser/ | Parser and AST generation (PEG parser in 3.9+) |
Key C Structures
PyObject - Everything is an Object
/* From Include/object.h */
typedef struct _object {
Py_ssize_t ob_refcnt; /* Reference count */
PyTypeObject *ob_type; /* Type of the object */
} PyObject;
/* Every Python object starts with this structure */PyTypeObject - Type Information
/* From Include/object.h (simplified) */
typedef struct _typeobject {
PyObject_VAR_HEAD
const char *tp_name; /* For printing, like "<class 'int'>" */
Py_ssize_t tp_basicsize; /* Size of object */
/* Methods */
destructor tp_dealloc; /* Called when refcount reaches 0 */
reprfunc tp_repr; /* __repr__ implementation */
hashfunc tp_hash; /* __hash__ implementation */
/* ... many more fields ... */
} PyTypeObject;Python Integer Implementation
/* From Objects/longobject.c */
/* Python integers can be arbitrarily large (not fixed to 64 bits) */
struct _longobject {
PyObject_VAR_HEAD
digit ob_digit[1]; /* Array of "digits" (30-bit chunks) */
};
/* Small integers (-5 to 256) are cached for performance */
#define NSMALLNEGINTS 5
#define NSMALLPOSINTS 257
/* This is why "a = 1; b = 1; a is b" returns True */Finding Features in CPython
Want to understand...
- List implementation?
Objects/listobject.c - Dictionary hashing?
Objects/dictobject.c - Generator mechanics?
Objects/genobject.c - GIL implementation?
Python/ceval_gil.c - Import system?
Python/import.c
Resources for Exploration
- CPython Source:github.com/python/cpython
- CPython Internals Book by Anthony Shaw
- Python Developer's Guide (devguide.python.org)
- PEPs (Python Enhancement Proposals) for feature design
Practical Example: Exploring Reference Counting
import sys
x = []
print(f"Initial refcount: {sys.getrefcount(x)}") # x + getrefcount's own arg
y = x # Another reference
print(f"After y = x: {sys.getrefcount(x)}")
del y
print(f"After del y: {sys.getrefcount(x)}")
# When refcount reaches 0, CPython calls tp_dealloc (C function)
# This is how automatic memory management worksExpected Output:
Initial refcount: 2 After y = x: 3 After del y: 2
Key Takeaways
- Bytecode is the intermediate language. Understanding dis module output reveals why certain patterns (like comprehensions) are faster.
- Python's execution model uses code objects and frame objects. Every function has a code object; every call creates a frame with local variables and execution state.
- The GIL prevents parallel bytecode execution. Use multiprocessing for CPU-bound tasks, threading/asyncio for I/O-bound tasks. CPython 3.13+ explores per-interpreter GIL.
- Import system has three layers: sys.modules cache, finders, and loaders. Custom import hooks enable powerful metaprogramming (load from network, apply transformations).
- AST manipulation enables code transformation. Parse, modify, and compile AST to add features, optimize code, or generate code programmatically.
- CPython is open source and readable. Exploring Objects/, Python/, and Include/ directories reveals how built-in types, the GIL, and the interpreter work at the C level.
What's Next?
With a deep understanding of Python's internals, you're ready to:
- Lesson 8: Context Managers & Protocols - Leverage your knowledge of execution flow and frame objects to master advanced context management patterns.
- Lesson 9: C Extensions & Performance - Build C extensions that release the GIL and interface with CPython's C API directly.
- Contribute to CPython - Explore the source, fix bugs, or propose enhancements through PEPs.
- Build debugging/profiling tools - Use frame inspection, AST manipulation, and bytecode analysis to create custom development tools.