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:

  1. Parses source code into an Abstract Syntax Tree (AST)
  2. Compiles AST to bytecode
  3. Executes bytecode in the Python Virtual Machine

From source to execution

.py sourcetextASTast.parse()Bytecode.pycPVMexecutesparsecompileexecute

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_VALUE
Understanding 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_VALUE
Every iteration pays for a LOAD_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]
The comprehension replaces both with a single 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')
Key Instructions: LOAD_FAST, LOAD_CONST, COMPARE_OP, POP_JUMP_IF_FALSE (conditional branching)
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_VALUE
Notice: LOAD_DEREF instruction loads closure variables (slower than LOAD_FAST for locals)

2. 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 file
2. Parse
AST
3. Compile
Bytecode
4. Execute
PVM

Code 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: 1
Result: Code objects are immutable and contain everything needed to execute a function

Frame 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}
Use case: Debugging tools, profilers, and traceback use frames to inspect execution
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)
Note: the same walk a traceback does, but during normal execution. The line number on each frame is where that frame is currently suspended, which is the line holding the call below it.

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?

  1. Memory Management: CPython's reference counting isn't thread-safe. Without the GIL, every object would need locks, severely impacting single-threaded performance.
  2. C Extension Compatibility: Many C extensions assume single-threaded execution. The GIL makes writing C extensions simpler and safer.
  3. 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
Result: the counter is wrong because of the race, and the run is slower than doing the work on one thread. The exact figure is different every time: five runs here gave 20002, 20000, 20000, 20000 and 20000. What is stable is that it lands near 20000 rather than 40000, meaning one thread's 20000 increments were almost entirely overwritten by the other.
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
Key Insight: threading helps I/O-bound tasks but not CPU-bound ones. The CPU-bound ratio hovers at 1.0x to 1.1x because the GIL lets only one thread run bytecode; the I/O-bound ratio sits near 9.8x because 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
ApproachUse CaseProsCons
multiprocessingCPU-bound tasksTrue parallelism, separate memoryHigher overhead, no shared memory
asyncioI/O-bound, many connectionsEfficient, single-threadedRequires async/await, different paradigm
NumPy/CythonNumerical computationReleases GIL, very fastLimited to specific domains
C ExtensionsPerformance-critical codeFull control, can release GILComplex, 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:

  1. Checks sys.modules cache (if found, return cached module)
  2. Searches sys.path for the module
  3. Uses finders to locate module spec
  4. Uses loaders to load and execute module
  5. 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
Performance Tip: import caching in 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
Common paths: the script's own directory first (/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
The import works even though no .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 Python
Expected Output:
first version
first version
second version
Use case: IPython/Jupyter auto-reload extensions use this. Note the middle line: re-running 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
Optimization: lazy imports reduce startup time for CLI tools. The first import costs tens of milliseconds and pulls in 100 extra modules; the second costs a few microseconds, four orders of magnitude less, because it only looks the name up in 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
Output: Lists all functions with their arguments and line numbers

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
Neither 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
Result: Function created entirely from AST, no source code!
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

DirectoryContents
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 works
Expected Output:
Initial refcount: 2
After y = x: 3
After del y: 2
Internals: Every assignment increments ob_refcnt in the PyObject struct

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.