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.

⚡ Module 7 11 min read Not started

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

  1. Describe the object model and reference counting.
  2. Read CPython bytecode (dis) and explain common opcodes.
  3. Explain the GIL and free-threaded build.
  4. Read C-level structures (PyObject, PyTypeObject) at a high level.
  5. Understand the cyclic garbage collector and weakref.

1. Everything is a PyObject

Every Python value is a heap-allocated C PyObject:

c
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 (a PyTypeObject).

Subclasses (like PyLongObject, PyFloatObject, PyListObject) add their own fields after the header.

See it from Python

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:

python
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 interning

Use == for value, is for identity (almost always ==).


2. The eval loop and bytecode

Source → AST → bytecode → CPython evaluation loop.

python
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_VALUE

The 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

OpcodeEffect
LOAD_FAST, STORE_FASTlocal variable (cell in array)
LOAD_GLOBAL, STORE_GLOBALmodule-level
LOAD_CONSTconstants in code object
LOAD_ATTR, STORE_ATTRobj.attr
LOAD_DEREF, STORE_DEREFclosure cell
CALL, RETURN_VALUEfunction call / return
BINARY_OP, COMPARE_OParithmetic / comparison
BUILD_LIST, BUILD_DICTcontainer literals
GET_ITER, FOR_ITERfor-loop iteration
JUMP_FORWARD, JUMP_BACKWARDcontrol flow
POP_JUMP_IF_FALSEif x: branches
RESUME, SENDgenerator/coroutine state
BEFORE_WITH, WITH_EXCEPT_STARTwith 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

python
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_filename

3. Names and scope mechanics

LEGB lookup (Local, Enclosing, Global, Built-in):

  • Local: LOAD_FAST indexes into a fixed-size array of cells.
  • Enclosing: LOAD_DEREF reads a cell object captured by a closure.
  • Global: LOAD_GLOBAL looks 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:

python
def hot_loop(xs):
    local_sum = sum            # bind to local
    return local_sum(x*x for x in xs)

globals(), locals(), __dict__

python
globals()             # module namespace
locals()              # function namespace (live)
self.__dict__         # instance attributes
type(self).__dict__   # class attributes / methods

4. 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):

  1. LOAD_FAST obj
  2. LOAD_ATTR method → descriptor protocol on type(obj)'s MRO.
  3. CALL with arg.

The MRO is precomputed using C3 linearisation when the class is created.

Method calls and bound methods

python
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.

python
import sys
x = object()
sys.getrefcount(x)      # 2 (one for `x`, one for the call's local arg)

Predictable destruction:

python
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:

python
a = []; b = []
a.append(b); b.append(a)
# both have refcount >= 1 forever from each other

A periodic cyclic GC traces objects in three generations and breaks cycles. Tunable:

python
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

python
import weakref
class Big: pass
b = Big()
r = weakref.ref(b)
r()                  # Big object
del b
r()                  # None — GC freed it

Used 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+)

powershell
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.

python
# 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

c
Py_BEGIN_ALLOW_THREADS
// pure C work; no Python objects touched
do_heavy_work();
Py_END_ALLOW_THREADS

That'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

python
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)

  1. dis.dis three small functions; identify the opcodes.
  2. Compare dis.dis output for if x is None: ... vs if x == None: ... — note IS_OP vs COMPARE_OP.
  3. Use sys.getrefcount to trace refcount changes.
  4. Build a reference cycle; confirm it isn't freed until gc.collect().
  5. Use weakref to break the cycle.
  6. Run a CPU-bound function on 4 threads in 3.12; observe no speedup. Then in 3.13t free-threaded; observe scaling.
  7. Read the Python/ceval.c for one opcode (e.g., BINARY_OP) and trace its work — even at a high level, it's enlightening.

Common pitfalls

  1. Assuming __del__ runs at scope exit when there's a reference cycle.
  2. Using is for value comparison ("works for small ints, breaks for large").
  3. Storing huge objects in globals() then wondering why they don't free.
  4. Disabling GC permanently — eventually you'll OOM on cycles.
  5. Expecting threads to scale CPU work in standard CPython.
  6. Assuming bytecode is stable across Python versions — it changes every release.

Self-check

  1. What is the GIL and why does it exist?
  2. State the steps from source code to running bytecode.
  3. What does LOAD_FAST do?
  4. How does the cyclic GC work?
  5. 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.