CPython internals — reference counts, the GIL, and free-threading
How CPython manages memory and locks, and what changes with the 3.13+ free-threaded interpreter.
Why this matters
Knowing how CPython works behind the curtain explains why some patterns are fast and others aren't, lets you read tracebacks and disassemblies fluently, and prepares you for free-threaded Python, JIT (3.13+), sub-interpreters (3.14), and writing C extensions. This is the lesson that turns "knows Python" into "knows Python."
Learning objectives
- Describe the object model and reference counting.
- Read CPython bytecode (
dis) and explain common opcodes. - Explain the GIL and free-threaded build.
- Read C-level structures (
PyObject,PyTypeObject) at a high level. - Understand the cyclic garbage collector and
weakref.
1. Everything is a PyObject
Every Python value is a heap-allocated C PyObject:
typedef struct _object {
Py_ssize_t ob_refcnt;
PyTypeObject *ob_type;
} PyObject;ob_refcnt: reference count. When it hits 0 → free.ob_type: pointer to the type object (aPyTypeObject).
Subclasses (like PyLongObject, PyFloatObject, PyListObject) add their own fields after the header.
See it from Python
import sys, ctypes
x = []
sys.getrefcount(x) # 2 (one is the temporary inside getrefcount)
id(x) # memory address
type(x) # <class 'list'>
type(x).__name__
sys.getsizeof(x) # bytes (shallow)Small-int / string interning
CPython caches int from -5 to 256 and short identifier-like strings:
a, b = 100, 100; a is b # True
a, b = 1000, 1000; a is b # False (usually)
import sys
sys.intern("hello") is sys.intern("hello") # True; manual interningUse == for value, is for identity (almost always ==).
2. The eval loop and bytecode
Source → AST → bytecode → CPython evaluation loop.
import dis
def f(x, y):
return x + y * 2
dis.dis(f)Output (3.12+):
3 RESUME 0
LOAD_FAST x
LOAD_FAST y
LOAD_CONST 2
BINARY_OP 5 (*)
BINARY_OP 0 (+)
RETURN_VALUEThe eval loop is Python/ceval.c. A giant switch (or computed-goto) over opcodes. Each opcode pops/pushes values from a stack.
Useful opcodes to recognise
| Opcode | Effect |
|---|---|
LOAD_FAST, STORE_FAST | local variable (cell in array) |
LOAD_GLOBAL, STORE_GLOBAL | module-level |
LOAD_CONST | constants in code object |
LOAD_ATTR, STORE_ATTR | obj.attr |
LOAD_DEREF, STORE_DEREF | closure cell |
CALL, RETURN_VALUE | function call / return |
BINARY_OP, COMPARE_OP | arithmetic / comparison |
BUILD_LIST, BUILD_DICT | container literals |
GET_ITER, FOR_ITER | for-loop iteration |
JUMP_FORWARD, JUMP_BACKWARD | control flow |
POP_JUMP_IF_FALSE | if x: branches |
RESUME, SEND | generator/coroutine state |
BEFORE_WITH, WITH_EXCEPT_START | with blocks |
Specialising adaptive interpreter (3.11+)
3.11's "Faster CPython" introduced specialised opcodes: after running an instruction a few times, the interpreter swaps it for a fast version tuned to the observed types (BINARY_OP_ADD_INT, LOAD_ATTR_INSTANCE_VALUE, etc.). This is why 3.11 is ~25% faster than 3.10 for typical code, no source changes needed.
Code objects
f.__code__.co_argcount # 2
f.__code__.co_varnames # ('x', 'y')
f.__code__.co_consts # (None, 2)
f.__code__.co_names # () for globals/attrs
f.__code__.co_freevars # closure cells
f.__code__.co_filename3. Names and scope mechanics
LEGB lookup (Local, Enclosing, Global, Built-in):
- Local:
LOAD_FASTindexes into a fixed-size array of cells. - Enclosing:
LOAD_DEREFreads acellobject captured by a closure. - Global:
LOAD_GLOBALlooks up in the module's__dict__. - Built-in: same path, falls through to the builtins module.
Local lookups are ~3× faster than global. The classic micro-opt:
def hot_loop(xs):
local_sum = sum # bind to local
return local_sum(x*x for x in xs)globals(), locals(), __dict__
globals() # module namespace
locals() # function namespace (live)
self.__dict__ # instance attributes
type(self).__dict__ # class attributes / methods4. Type objects
A class is itself an object — an instance of type. The PyTypeObject C struct holds:
tp_name,tp_basicsize,tp_itemsize- function pointers for
tp_new,tp_init,tp_dealloc,tp_call - slot pointers for
tp_richcompare,tp_iter,tp_hash, etc. - the type's
__dict__ - its MRO (method resolution order)
When you write obj.method(arg):
LOAD_FAST objLOAD_ATTR method→ descriptor protocol ontype(obj)'s MRO.CALLwitharg.
The MRO is precomputed using C3 linearisation when the class is created.
Method calls and bound methods
class C:
def m(self): ...
c = C()
c.m # <bound method ...>
c.m == C.m # False (bound vs unbound function)
c.m() # calls C.m(c)c.m triggers function.__get__(c, C) → returns a bound method object that closes over c. Modern CPython optimises this with LOAD_METHOD + CALL (avoid creating the bound method).
5. Memory: refcounting + cyclic GC
Reference counting
Every object has a count. Each new reference (binding, argument passing, list insertion) bumps it; each unbinding decrements. At zero, tp_dealloc runs immediately.
import sys
x = object()
sys.getrefcount(x) # 2 (one for `x`, one for the call's local arg)Predictable destruction:
class Closer:
def __del__(self): print("freed")
c = Closer()
c = None # prints "freed" immediately(Unlike Java, where finalize is non-deterministic.)
Cyclic GC
Refcounting can't free cycles:
a = []; b = []
a.append(b); b.append(a)
# both have refcount >= 1 forever from each otherA periodic cyclic GC traces objects in three generations and breaks cycles. Tunable:
import gc
gc.get_threshold() # (700, 10, 10) by default
gc.set_threshold(700, 10, 10)
gc.collect() # force a pass
gc.disable(); gc.enable()Long-running servers sometimes disable GC during request handling (cuts latency tail) and run it explicitly between requests.
weakref — references that don't count
import weakref
class Big: pass
b = Big()
r = weakref.ref(b)
r() # Big object
del b
r() # None — GC freed itUsed by caches (weakref.WeakValueDictionary) and parent links (avoid cycles).
6. The GIL and the free-threaded future
The GIL serialises bytecode execution. Released during:
- I/O syscalls (
recv,read). - Long sleeps.
- C extensions that explicitly drop it (NumPy, hashlib, lxml).
Held during:
- Pure-Python loops.
- Most stdlib operations.
- Object creation / refcount updates.
It exists because refcounting is not atomic on multi-core: two threads decrementing the same counter could miss decrements. PEP 703 (free-threaded) uses biased reference counting + per-object mutexes + deferred refcounting on immortal objects to remove the GIL — at a small single-threaded cost.
Free-threaded build (3.13+)
uv python install 3.13t
uv run --python 3.13t python -c "import sys; print(sys._is_gil_enabled())"CPU-bound multi-thread workloads scale linearly. Adoption ongoing through 2026 — most pure-Python libraries work; many C extensions need updates.
Sub-interpreters (PEP 684, 734)
3.12 added per-interpreter GIL; 3.14 ships concurrent.interpreters to use them from Python. Each sub-interpreter has its own GIL → multiple Python interpreters can run in parallel within one process.
# 3.14+
import concurrent.interpreters as ci
interp = ci.create()
interp.exec("import math; print(math.pi)")Less mature than free-threaded; useful for sandboxing and CPU parallelism without the cost of separate OS processes.
7. C extensions and the API
The CPython C API lets you write extensions in C/C++. Modern alternatives:
ctypes: call any C shared library without compiling.cffi: nicer interface to C from Python.Cython: compile annotated Python to C.pybind11: C++ → Python bindings.PyO3: Rust → Python bindings.HPy: stable, version-portable Python C API.
For most users, Cython / PyO3 are the right tools. Raw C-API is for the brave (and library authors).
Why C extensions release the GIL
Py_BEGIN_ALLOW_THREADS
// pure C work; no Python objects touched
do_heavy_work();
Py_END_ALLOW_THREADSThat's how NumPy / hashlib / PyTorch let Python threads run in parallel during compute-heavy phases.
8. Why dict / class lookup is fast
CPython uses compact dicts (PEP 468 / GVR's "compact dict" design) — open addressing with quadratic probing, plus a separate "indices" array for compactness. Lookup is O(1) average, but in C with great cache behaviour.
Class attribute lookup specialises via the inline cache (3.11+) — after a few calls of obj.attr, the interpreter caches the offset, skipping the dict lookup. This is why writing local_attr = obj.attr once at the top of a hot loop is barely faster than re-accessing in 3.11+.
9. Watching CPython work — useful tools
import sys
sys.settrace(...) # callback on every line/call
sys.setprofile(...) # callback on every call/return
sys.monitoring # 3.12+ low-overhead instrumentation (PEP 669)
import dis
dis.dis(func) # bytecode listing
dis.show_code(func) # code object metadata
import gc
gc.get_objects() # every tracked object (huge list)
gc.get_referrers(obj) # who refers to this?
gc.is_tracked(obj) # is it monitored by GC?
from objgraph import show_growth, show_backrefs
# uv add objgraph — visualises object graphs (great for leak hunts)For real production debugging:
py-spy dump— print Python stacks of a running process.pyrasite-shell— attach a REPL to a running process.austin— frame stack profiler with low overhead.
10. Putting the model to use
Why a tight loop is slow
Each iteration: FOR_ITER, STORE_FAST, several attribute / function dispatches, refcount updates. ~50-200 ns per simple operation. A million iterations = ~50-200 ms baseline overhead.
Why NumPy is fast
One Python opcode invokes a C function that loops 1M times in native code, touching no PyObjects. The 50 ns per-iteration overhead disappears.
Why dict[key] is ~80 ns
A hash + lookup + reference increment. Faster than most operations in any other dynamic language.
Why class attribute access is so cheap (3.11+)
Specialised LOAD_ATTR_INSTANCE_VALUE caches the slot offset; lookups become near-direct memory reads.
Why threads can speed up I/O
The GIL releases during the syscall. Other threads run.
Why threads don't speed up pure-Python loops
The GIL never drops during bytecode execution; threads serialise.
11. The __future__ of CPython (2026)
- Free-threaded build moving from experimental → stable → eventually default.
- JIT compiler (PEP 744) maturing — copy-and-patch JIT yields modest speedups now, larger ones expected over 3.14/3.15.
- Sub-interpreters with their own GIL for parallel sandboxes.
- Specialising interpreter continues to gain new specialisations.
t-strings(PEP 750) for safe templated strings.
Watch the Faster CPython project for updates.
Hands-on lab (2 hours)
dis.disthree small functions; identify the opcodes.- Compare
dis.disoutput forif x is None: ...vsif x == None: ...— noteIS_OPvsCOMPARE_OP. - Use
sys.getrefcountto trace refcount changes. - Build a reference cycle; confirm it isn't freed until
gc.collect(). - Use
weakrefto break the cycle. - Run a CPU-bound function on 4 threads in 3.12; observe no speedup. Then in 3.13t free-threaded; observe scaling.
- Read the
Python/ceval.cfor one opcode (e.g.,BINARY_OP) and trace its work — even at a high level, it's enlightening.
Common pitfalls
- Assuming
__del__runs at scope exit when there's a reference cycle. - Using
isfor value comparison ("works for small ints, breaks for large"). - Storing huge objects in
globals()then wondering why they don't free. - Disabling GC permanently — eventually you'll OOM on cycles.
- Expecting threads to scale CPU work in standard CPython.
- Assuming bytecode is stable across Python versions — it changes every release.
Self-check
- What is the GIL and why does it exist?
- State the steps from source code to running bytecode.
- What does
LOAD_FASTdo? - How does the cyclic GC work?
- Why is NumPy fast?
References
- CPython Internals, Anthony Shaw.
- Inside the Python Virtual Machine, Obi Ike-Nwosu (free).
- CPython source: https://github.com/python/cpython.
- "Faster CPython" project: https://github.com/faster-cpython/ideas.
- PEP 703 — Free-threaded CPython.
- PEP 744 — JIT compiler.
- David Beazley, "Understanding the Python GIL" (talk).
- Raymond Hettinger, "Modern Python" talks.
Sign in to save your progress and earn badges.