Python Internals: CPython, Bytecode & Memory
After this lesson, you will be able to:
- Trace the full pipeline from .py source file to CPython VM execution
- Read and interpret Python bytecode using the dis module
- Explain reference counting and when objects are freed from memory
- Describe how cyclic GC's three-generation model handles reference cycles
- Explain the GIL: why it exists, what it blocks, and how to work around it
- Use sys.getsizeof, sys.getrefcount, gc, and tracemalloc for memory insight
- Profile Python performance with cProfile and timeit
- Choose between CPython, PyPy, and Cython for different workloads
Before You Start
#How Python Runs Your Code
python script.py, a surprising amount happens before a single line of your logic executes. Python is often called an "interpreted" language, but that's an oversimplification. It's actually compiled to bytecode, which is then executed by a virtual machine.Here's the full pipeline — click through each stage to see what your code looks like at every step:
#Step 1: Lexing
x = 1 + 2 becomes:NAME 'x'
OP '='
NUMBER '1'
OP '+'
NUMBER '2'
NEWLINE
#Step 2: Parsing → AST
import ast
source = "x = 1 + 2"
tree = ast.parse(source)
print(ast.dump(tree, indent=2))Output:
Module(
body=[
Assign(
targets=[Name(id='x', ctx=Store())],
value=BinOp(
left=Constant(value=1),
op=Add(),
right=Constant(value=2)
)
)
],
type_ignores=[]
)
The AST is completely language-agnostic at this point. It just describes what operations need to happen, not how.
#Step 3: Compilation → Bytecode
.pyc files inside __pycache__/.import py_compile
py_compile.compile("script.py")
# Creates __pycache__/script.cpython-312.pyc.pyc file contains:- A magic number (identifies Python version)
- Timestamps/source hash (for cache invalidation)
- The marshaled
codeobject (bytecode + constants + names)
Python Bytecode with the dis Module
dis module lets you disassemble Python functions and see the actual bytecode instructions CPython will execute.import dis
def add(a, b):
return a + b
dis.dis(add)Output:
2 0 RESUME 0
3 2 LOAD_FAST 0 (a)
4 LOAD_FAST 1 (b)
6 BINARY_OP 0 (+)
10 RETURN_VALUE
#Reading the Output
line_number offset OPCODE arg (human-readable-arg)| Instruction | Meaning |
|---|---|
LOAD_FAST | Push a local variable onto the stack |
BINARY_OP | Pop top two stack items, apply operation, push result |
RETURN_VALUE | Pop top of stack and return it |
#For Loop vs List Comprehension
import dis
def with_loop(n):
result = []
for i in range(n):
result.append(i * 2)
return result
def with_comprehension(n):
return [i * 2 for i in range(n)]
print("=== FOR LOOP ===")
dis.dis(with_loop)
print("\n=== LIST COMPREHENSION ===")
dis.dis(with_comprehension)LIST_APPEND opcode that's more efficient than list.append. Comprehensions also run in their own code object, which keeps the namespace clean.a += b vs a = a + b
import dis
def inplace(a, b):
a += b
return a
def regular(a, b):
a = a + b
return a
dis.dis(inplace) # Uses INPLACE_ADD
dis.dis(regular) # Uses BINARY_OP (Add)+= calls __iadd__ which modifies in-place (no new object). For immutable objects like integers and strings, both produce identical behavior — CPython falls back to __add__.#Lambda Bytecode
import dis
fn = lambda x: x * 2 + 1
dis.dis(fn)
# LOAD_FAST 'x', LOAD_CONST 2, BINARY_OP (*), LOAD_CONST 1, BINARY_OP (+), RETURN_VALUEdef function with a single expression body.x = a + b — step through every bytecode instruction
1 / 9a = 2
b = 3
x = a + bLOAD_CONST 2: Push the int 2 onto the evaluation stack.
Python/ceval.c is a giant switch over opcodes that manipulates this stack.#See the VM run, instruction by instruction
BytecodeStepper above is great for tracing one snippet. The animated stepper below lets you flip between five canonical scenarios — simple arithmetic, a function call, a branch, a loop, and a tuple-vs-list optimization — and watch the instruction pointer glide across the bytecode while the evaluation stack pushes/pops and locals get bound in real time. Loops literally trace a circle. Jumps draw a curved arrow. That's what your code looks like to the VM.LOAD_CONST, while lists must be assembled at runtime with three LOAD_CONST + one BUILD_LIST. That's why (1,2,3) is a few times faster to "create" than [1,2,3] — even though they look almost identical in source.What does dis.dis() reveal about Python list comprehensions vs equivalent for loops?
#Reference Counting
ob_refcnt field — an integer that counts how many references point to it. This is CPython's primary memory management mechanism.#Rules:
- When you assign an object to a variable → refcount +1
- When a variable goes out of scope or is reassigned → refcount -1
- When refcount reaches 0 → the object is immediately freed
import sys
x = [1, 2, 3]
print(sys.getrefcount(x)) # 2 (x + the function argument)
y = x # y also references the same list
print(sys.getrefcount(x)) # 3
del y # remove y's reference
print(sys.getrefcount(x)) # 2 again
# When x goes out of scope (e.g., function returns), refcount → 0, list is freed#Refcount in Action
import sys
a = "hello" # refcount = 1 (just 'a')
b = a # refcount = 2 (a and b)
c = [a, b] # refcount = 4 (a, b, c[0], c[1])
del a
del b
# refcount is still 2 (c[0] and c[1] still reference it)
del c
# refcount → 0 → string "hello" is freed#CPython vs Java GC
| Feature | CPython (Refcount) | Java (Tracing GC) |
|---|---|---|
| Collection trigger | Immediate (refcount = 0) | Periodic / threshold |
| Pause time | Near-zero (most frees are instant) | Stop-the-world GC pauses |
| Overhead | Per-operation refcount update | Tracing scan overhead |
| Cycles | Can't handle (needs cyclic GC) | Handles naturally |
__del__ is called the moment refcount hits zero (unless there's a cycle).#The Cyclic Garbage Collector
Reference counting frees an object the instant its count hits zero. If it is that immediate, why does CPython also ship a separate garbage collector?
a = []
a.append(a) # a references itself!
# a's refcount is 2 (the variable 'a' + the element a[0])
del a
# The variable 'a' is removed → refcount drops to 1
# But the list still references itself → refcount never reaches 0
# MEMORY LEAK without the cyclic GC!gc module implements a generational garbage collector specifically for cycles.#Three Generations
Objects are divided into three generations based on survival:
Gen 0: Newly created objects (collected most often)
Gen 1: Survived one gen-0 collection
Gen 2: Survived one gen-1 collection (collected least often)
The intuition: most objects die young (temporary variables, loop iteration values). Objects that survive are likely long-lived (module-level data, caches). Collecting gen-0 frequently is cheap and catches most garbage.
import gc
# Check current counts (objects in gen0, gen1, gen2)
print(gc.get_count()) # e.g., (312, 5, 1)
# Thresholds: collect gen-n when count exceeds threshold
print(gc.get_threshold()) # (700, 10, 10) — defaults
# Force a full collection
collected = gc.collect()
print(f"Collected {collected} unreachable objects")
# Check counts again
print(gc.get_count())#Creating a Cycle
import gc
class Node:
def __init__(self, val):
self.val = val
self.next = None
# Create a cycle
a = Node(1)
b = Node(2)
a.next = b
b.next = a # cycle!
del a, b # refcounts drop but never reach 0
gc.collect() # cyclic GC finds and frees themgc.disable() for Performance
import gc
gc.disable() # Stop automatic cyclic GC
# ... run performance-critical code with no cycles ...
gc.enable() # Re-enable
gc.collect() # Manual cleanuporjson do this internally.__del__ and GC Problems
__del__ methods that are part of a reference cycle cannot be collected by older Python versions. Python 3.4+ (PEP 442) improved this, but finalizers still add overhead and can delay collection.class Problematic:
def __del__(self):
print("Being deleted")
a = Problematic()
b = Problematic()
a.ref = b
b.ref = a # cycle with __del__
del a, b
# Python 3.4+: collected, but in "safe" order determined by GC#The GIL (Global Interpreter Lock)
The GIL is one of the most discussed (and misunderstood) features of CPython. It's a mutex, a mutual exclusion lock, that ensures only one thread executes Python bytecode at a time.
#Why Does It Exist?
ob_refcnt, both increment it, and write back, but only one increment would take effect. Objects would be freed prematurely → segfaults.The GIL is the simplest solution: allow only one thread to run Python code at once. Reference counting is then safe without per-object locks.
#Impact on CPU-Bound Code
import threading
import time
def count_up(n):
x = 0
for _ in range(n):
x += 1
return x
# Single thread
start = time.time()
count_up(100_000_000)
print(f"Single thread: {time.time() - start:.2f}s")
# Two threads — same CPU time, no speedup!
start = time.time()
t1 = threading.Thread(target=count_up, args=(50_000_000,))
t2 = threading.Thread(target=count_up, args=(50_000_000,))
t1.start(); t2.start()
t1.join(); t2.join()
print(f"Two threads: {time.time() - start:.2f}s")
# Both take ~the same time — GIL prevents true parallelism#IO-Bound Code IS Helped by Threading
import threading
import urllib.request
def fetch(url):
urllib.request.urlopen(url) # GIL released during network wait!
# Multiple threads CAN run concurrently for IO
# (the GIL is released when a thread waits for IO)#GIL Release Mechanics
- Python 2: Released every 100 bytecode instructions
- Python 3.2+: Released every 5ms (configurable via
sys.setswitchinterval) - C extensions: Can manually release GIL with
Py_BEGIN_ALLOW_THREADSmacro (NumPy and SciPy do this for array operations)
import sys
print(sys.getswitchinterval()) # 0.005 (5ms)
sys.setswitchinterval(0.01) # Switch every 10ms#Working Around the GIL
| Problem | Solution |
|---|---|
| CPU-bound parallelism | multiprocessing — separate processes, each has its own GIL |
| IO-bound concurrency | threading or asyncio |
| CPU-bound in C | C extensions that release GIL (NumPy, SciPy) |
| CPU-bound interpreted | PyPy (has its own GIL but faster execution) |
NumPy runs CPU-heavy array maths across multiple threads at full speed despite the GIL. How?
#PEP 703: The No-GIL Project
Python 3.13 introduced experimental support for running without the GIL (opt-in). Python 3.14+ may enable it more broadly. The challenge: removing the GIL requires replacing per-refcount updates with atomic operations, and ensuring all C extensions are thread-safe.
#Integer Caching
a = 256; b = 256; a is b gives True, but with 257 it gives False. What does that tell you about using is for numbers?
a = 100
b = 100
print(a is b) # True — SAME object in memory
print(id(a) == id(b)) # True
a = 1000
b = 1000
print(a is b) # True — but NOT because of the small-int cache
print(id(a) == id(b)) # True — `a is b` and id(a) == id(b) are the same test
# Why is it True? Both literals sit in the SAME code object, so the compiler
# stores 1000 once in co_consts and points both names at it. Type these two
# lines one at a time in the REPL and you get False, because each line is
# compiled separately. Never rely on either outcome.
# Build the ints at run time and the dedup cannot happen:
c = int("1000")
d = int("1000")
print(c is d) # False — two separately allocated objects
print(c == d) # True — equal value is the thing you actually wanted
# The small-int cache is different: it survives run-time construction.
print(int("100") is int("100")) # True — 100 is in the -5..256 rangeis
happens to agree with ==. Outside it, two objects, and the agreement was
never something the language promised.#Why -5 to 256?
malloc/free calls and improves cache locality. The range is an implementation detail of CPython — PyPy may cache more, MicroPython fewer.#String Interning
Python automatically interns strings that look like identifiers (no spaces, valid variable names):
a = "hello"
b = "hello"
print(a is b) # True — interned automatically
a = "hello world"
b = "hello world"
print(a is b) # True — one shared constant in this code objectsys.intern():import sys
a = sys.intern("hello world")
b = sys.intern("hello world")
print(a is b) # True — now internedsys.intern(): When you have a large lookup table or dictionary with many repeated string keys that are loaded at runtime (not literals). Interning makes is comparison O(1) instead of O(n) character comparison.The id() Function
id(obj) returns the memory address of the object (in CPython). This is how is comparison works internally — a is b is equivalent to id(a) == id(b).x = [1, 2, 3]
print(id(x)) # e.g., 140234567890
y = x
print(id(y)) # Same address — same object
z = [1, 2, 3]
print(id(z)) # Different address — new object#Memory Layout of Python Objects
Every object in CPython is a C struct. At minimum, every Python object has:
typedef struct _object {
Py_ssize_t ob_refcnt; // reference count (8 bytes on 64-bit)
PyTypeObject *ob_type; // pointer to type object (8 bytes)
} PyObject;
An integer (PyLongObject) adds:
typedef struct {
PyObject ob_base; // base PyObject (16 bytes)
Py_ssize_t ob_size; // number of digits (8 bytes)
digit ob_digit[1]; // the actual integer digits
} PyLongObject;
This is why Python objects have overhead compared to C primitives:
import sys
print(sys.getsizeof(1)) # 28 bytes (vs 4 bytes for C int32)
print(sys.getsizeof(1.0)) # 24 bytes (vs 8 bytes for C double)
print(sys.getsizeof(True)) # 28 bytes (bool inherits from int)
print(sys.getsizeof("hello")) # 54 bytes (vs 5 bytes for C char[])
print(sys.getsizeof("")) # 49 bytes (empty string overhead)
print(sys.getsizeof([])) # 56 bytes (empty list)
print(sys.getsizeof([1,2,3])) # 88 bytes (list + 3 pointers)
print(sys.getsizeof((1,2,3))) # 64 bytes (tuple is more compact)
print(sys.getsizeof({})) # 64 bytes (empty dict)sys.getsizeof returns the shallow size — it doesn't recursively count the objects that a list's elements point to. Use tracemalloc or the pympler package for deep memory accounting.#Why This Matters for ML
float32 arrays store 4 bytes per element (C floats). A Python list of distinct floats stores 24 bytes per float object plus 8 bytes per pointer in the list. That's 9x more memory overhead for pure Python vs NumPy.import sys
import numpy as np
# 1 million floats
# NOTE: [0.0] * 1_000_000 would NOT show this — it makes a million references
# to ONE shared float, so the float overhead is 24 bytes total, not 24 MB.
python_list = [float(i) for i in range(1_000_000)]
numpy_array = np.zeros(1_000_000, dtype=np.float32)
# Rough comparison:
# Python list: ~8MB (pointers) + ~24MB (float objects) = ~32MB
# NumPy float32: 4MB exactly
print(sys.getsizeof(python_list)) # ~8MB for the list container
print(numpy_array.nbytes) # 4,000,000 bytes = 4MBThis is the core reason to use NumPy for ML: memory efficiency and vectorized C operations with no GIL overhead.
#Python's Memory Allocator (PyMalloc)
malloc directly for every object allocation. It has a custom allocator, PyMalloc, layered on top of the OS allocator.#The Three Tiers
Tier 1 (OS): malloc/free for large allocations (> 512 bytes)
Tier 2 (Arena): Python requests 256KB chunks from the OS
Tier 3 (Pool): 4KB pools carved from arenas, fixed-size blocks
- Arenas (256KB) are requested from the OS
- Each arena is divided into pools (4KB each)
- Each pool holds blocks of a single fixed size (8, 16, 24, ..., 512 bytes)
- Allocation = find a free block in the right size pool = O(1) + no fragmentation
malloc for every tiny object and reduces memory fragmentation.Memory Profiling with tracemalloc
import tracemalloc
tracemalloc.start()
# --- Code to profile ---
data = [dict(x=i, y=i*2) for i in range(10000)]
# --- End of code ---
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics("lineno")
print("Top 5 memory allocations:")
for stat in top_stats[:5]:
print(stat)Output (example):
Top 5 memory allocations:
profiling.py:6: size=2344 KiB, count=70001, average=34 B
profiling.py:7: size=984 KiB, count=10000, average=101 B
import tracemalloc
tracemalloc.start()
snapshot1 = tracemalloc.take_snapshot()
# ... run suspicious code ...
leaky_data = [object() for _ in range(100000)]
snapshot2 = tracemalloc.take_snapshot()
top_stats = snapshot2.compare_to(snapshot1, "lineno")
for stat in top_stats[:5]:
print(stat)Tests · assert sys.getsizeof(1) > 0; assert sys.getsizeof([]) < sys.getsizeof([1,2,3])
#Interactive Visualization: CPython Internals Explorer
Step through bytecode execution on a virtual stack machine, see the GIL's thread scheduling, and explore PyMalloc's memory pool hierarchy:
#Performance Profiling
cProfile: Function-Level CPU Profiling
# Profile a script from the command line
python -m cProfile -s cumulative script.py
# Profile with output to file
python -m cProfile -o output.prof script.py
In code:
import cProfile
import pstats
import io
def slow_function():
return sum(i**2 for i in range(100000))
pr = cProfile.Profile()
pr.enable()
slow_function() # code to profile
pr.disable()
stream = io.StringIO()
ps = pstats.Stats(pr, stream=stream).sort_stats("cumulative")
ps.print_stats(10) # top 10 functions by cumulative time
print(stream.getvalue())timeit: Micro-benchmarks
import timeit
# Quick one-liner
print(timeit.timeit("x = [i*2 for i in range(100)]", number=100000))
# Compare two approaches
setup = "data = list(range(1000))"
t1 = timeit.timeit("result = [x*2 for x in data]", setup=setup, number=10000)
t2 = timeit.timeit("result = list(map(lambda x: x*2, data))", setup=setup, number=10000)
t3 = timeit.timeit(
"import numpy as np; np.array(data) * 2",
setup="data = list(range(1000))",
number=10000
)
print(f"Comprehension: {t1:.3f}s")
print(f"map+lambda: {t2:.3f}s")
print(f"NumPy: {t3:.3f}s")#Common Performance Patterns
# 1. Local variable access is faster than global
import math
def slow():
result = 0
for i in range(100000):
result += math.sqrt(i) # global lookup each time
return result
def fast():
sqrt = math.sqrt # cache in local variable
result = 0
for i in range(100000):
result += sqrt(i) # local lookup (faster LOAD_FAST)
return result
# 2. join() is faster than string concatenation
slow_str = ""
for word in ["hello", "world", "foo", "bar"]:
slow_str += word # creates a new string object each time
fast_str = "".join(["hello", "world", "foo", "bar"]) # one allocation
# 3. in operator on set is O(1), on list is O(n)
lookup_set = {1, 2, 3, 1000000}
lookup_list = [1, 2, 3, 1000000]
# Checking membership:
1000000 in lookup_set # O(1) — hash lookup
1000000 in lookup_list # O(n) — linear scan
# 4. Avoid repeated dict lookups in inner loops
config = {"key": "value"}
for _ in range(100000):
val = config["key"] # dict lookup each time — slow
val = config["key"] # look up once
for _ in range(100000):
_ = val # local variable access — fastPyPy's JIT often runs CPU-bound Python several times faster than CPython. Why is CPython still the default everywhere?
#CPython vs Other Implementations
| Implementation | How It Works | Best For | Tradeoffs |
|---|---|---|---|
| CPython | Bytecode interpreter, reference counting | General use, maximum compatibility | Slowest at CPU-bound tasks |
| PyPy | JIT compiler (traces hot loops, compiles to machine code) | CPU-bound algorithms, long-running processes | Slower startup, higher memory, less C-extension compatibility |
| Jython | Python → JVM bytecode | Java ecosystem integration | No CPython C extensions, limited Python 3 support |
| MicroPython | Stripped-down CPython | Microcontrollers (ESP32, Raspberry Pi Pico) | Very limited stdlib |
| Cython | Compiles .pyx files to C extensions | Accelerating hot Python functions | Requires compilation, .pyx syntax |
| Numba | JIT for NumPy-heavy code (@jit decorator) | Scientific computing inner loops | Only works with NumPy and supported operations |
#When to Use PyPy
# This kind of code benefits enormously from PyPy:
def fibonacci(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
# CPython: ~2s for fibonacci(10_000_000)
# PyPy: ~0.05s — 40x speedup from JIT compilationPyPy works best when:
- Your code runs a tight loop for a long time (JIT warm-up amortized)
- Pure Python (no heavy C extension use)
- CPU-bound (GIL is still present in PyPy, but each instruction is faster)
#When to Use Cython
# fibonacci.pyx (Cython)
def fibonacci(int n):
cdef int a = 0, b = 1
cdef int i
for i in range(n):
a, b = b, a + b
return a
# Compiled: ~100x faster than CPython for this function
# Used by: NumPy, SciPy, pandas, scikit-learn internals
#Key Takeaways
- CPython runs in two phases: source → bytecode (compiler), then bytecode → results (interpreter/ceval loop) — understanding this explains why Python startup is slow but subsequent calls are faster
- Reference counting handles most memory immediately: when
refcountdrops to 0, CPython deallocates the object synchronously — no GC pause; the cyclic collector only runs for reference cycles that refcounting misses - The GIL serializes CPU-bound threads: two threads cannot execute Python bytecode in parallel; use
multiprocessingfor CPU parallelism orasynciofor I/O concurrency - CPython caches small integers (-5 to 256) and interned strings:
a is bcan returnTruefor separately created objects in this range — always use==for value comparison,isonly for identity - Profile before optimizing: use
cProfileto find the actual bottleneck; common wins are algorithmic changes, C extensions (NumPy, Pandas), and switching to PyPy for pure-Python CPU-bound workloads
#Summary
You now understand Python from the inside out:
| Layer | Key Concepts |
|---|---|
| Execution pipeline | .py → lexer → tokens → parser → AST → compiler → bytecode → CPython VM |
| Bytecode | dis module, stack machine, LOAD_FAST / BINARY_OP / RETURN_VALUE |
| Reference counting | ob_refcnt, sys.getrefcount(), immediate freeing when count = 0 |
| Cyclic GC | Three generations, gc.collect(), cycles never freed by refcounting alone |
| The GIL | One thread runs at a time, released for IO, use multiprocessingPython Multiprocessing`multiprocessing` spawns separate processes that bypass the GIL — true parallel CPU work at the cost of inter-process communication overhead.Learn more → for CPU parallelism |
| Integer caching | -5 to 256 are singletons; use == not is for value comparison |
| Memory layout | PyObject struct overhead, sys.getsizeof(), why NumPy beats Python lists |
| PyMalloc | Arena → pool → block hierarchy for sub-512-byte allocations |
| Profiling | cProfile, timeit, tracemalloc — measure before optimizing |
| Implementations | CPython (compat), PyPy (JIT speed), Cython (C extension), Numba (NumPy JIT) |
What does `sys.getrefcount(x)` always return at minimum, even when only one variable references the object?