CPython Internals Advanced¶
When you'd use this
Object model, reference counting, GIL, type slots and the C implementation.
Understand the interpreter when you're profiling, debugging memory, writing C extensions, or reasoning about the GIL and reference counting.
Everything is a PyObject¶
Every value is a heap object with a type and refcount — the uniform model behind Python.
In the C source, every Python object starts with a common header:
// Simplified from Include/object.h
typedef struct {
Py_ssize_t ob_refcnt; // reference count
PyTypeObject *ob_type; // pointer to type object
} PyObject;
This means: - sys.getrefcount(obj) reads ob_refcnt - type(obj) reads ob_type
import sys
x = []
print(sys.getrefcount(x)) # 2 (x + the getrefcount arg)
y = x
print(sys.getrefcount(x)) # 3 (x + y + the getrefcount arg)
del y
print(sys.getrefcount(x)) # 2
Reference Counting¶
CPython frees an object the instant its reference count hits zero. Understanding it explains memory behavior, why cycles need a separate collector, and the cost of holding references.
CPython's primary memory management is reference counting — each object tracks how many references point to it:
import sys
a = "hello" # refcount = 1
b = a # refcount = 2 (a and b)
c = [a, a, a] # refcount = 5 (a, b, c[0], c[1], c[2])
print(sys.getrefcount(a)) # 6 (includes getrefcount's own ref)
del b # refcount decreases
c.clear() # refcount decreases by 3
When refcount hits 0, the object is immediately deallocated — no waiting for a GC cycle.
Advantages:¶
- Deterministic destruction (predictable
__del__timing) - Low latency (no GC pauses for simple cases)
- Simple mental model
Disadvantages:¶
- Can't handle reference cycles (need supplementary GC)
- Thread-unsafe without the GIL
- Per-object overhead (8 bytes for refcount on 64-bit)
The GIL (Global Interpreter Lock)¶
Only one thread runs Python bytecode at a time. This is why threads help I/O-bound work but not CPU-bound work — for CPU parallelism use multiprocessing.
The GIL is a mutex that allows only one thread to execute Python bytecode at a time.
import threading, time
counter = 0
def increment():
global counter
for _ in range(1_000_000):
counter += 1 # NOT atomic — but GIL prevents corruption
threads = [threading.Thread(target=increment) for _ in range(2)]
for t in threads: t.start()
for t in threads: t.join()
# counter might be less than 2_000_000!
# GIL prevents memory corruption but NOT race conditions
# because += is multiple bytecodes: LOAD, ADD, STORE
When the GIL is released:¶
- During I/O operations (file read, network, sleep)
- During C extension calls that explicitly release it
- Every N bytecodes (sys.getswitchinterval(), default 5ms)
Implications:¶
- Multi-threaded CPU-bound code won't use multiple cores
- I/O-bound code benefits from threads (GIL released during I/O)
multiprocessingbypasses the GIL entirely (separate processes)
The future: free-threading (PEP 703)¶
Python 3.13+ has experimental support for running without the GIL:
Small Integer Cache¶
CPython pre-creates small ints (-5..256) so they're shared singletons.
CPython caches integers from -5 to 256:
a = 256
b = 256
print(a is b) # True — same object
a = 257
b = 257
print(a is b) # False — different objects (usually)
a = -5
b = -5
print(a is b) # True
a = -6
b = -6
print(a is b) # False
This exists purely as an optimization — small integers are used so frequently that caching avoids millions of allocations.
String Interning¶
Identical identifier-like strings share one object to speed comparisons.
CPython interns certain strings (stores only one copy):
a = "hello"
b = "hello"
print(a is b) # True — interned (looks like an identifier)
a = "hello world"
b = "hello world"
print(a is b) # False — contains space, not interned automatically
import sys
a = sys.intern("hello world")
b = sys.intern("hello world")
print(a is b) # True — manually interned
Interning rules: - Strings that look like identifiers ([a-zA-Z_][a-zA-Z0-9_]*) are auto-interned - Dictionary keys are interned - Module attribute names are interned - You can force-intern with sys.intern()
Object Allocation: pymalloc¶
CPython's specialized allocator for small objects.
CPython has its own memory allocator for small objects (< 512 bytes):
┌─────────────────────────────────┐
│ OS (malloc/mmap) │ ← large allocations
├─────────────────────────────────┤
│ Python Object Allocator │ ← arenas (256 KB)
│ └── Pools (4 KB each) │ └── blocks (8, 16, 24...512 bytes)
├─────────────────────────────────┤
│ Python Internal Buffer │ ← raw memory API
└─────────────────────────────────┘
import sys
# Every object has overhead
print(sys.getsizeof(0)) # 28 bytes (on 64-bit)
print(sys.getsizeof(1)) # 28 bytes
print(sys.getsizeof(2**30)) # 32 bytes
print(sys.getsizeof("")) # 49 bytes
print(sys.getsizeof("a")) # 50 bytes
print(sys.getsizeof([])) # 56 bytes
print(sys.getsizeof({})) # 64 bytes
Type Slots — How methods are dispatched¶
C-level function pointers that make built-in operations fast.
When you write a + b, CPython doesn't look up __add__ in a dictionary. Instead, it checks a type slot:
// Simplified: how + works internally
PyObject* binary_add(PyObject *a, PyObject *b) {
// Check the type's nb_add slot (C function pointer)
binaryfunc add_func = a->ob_type->tp_as_number->nb_add;
if (add_func) {
return add_func(a, b);
}
// ... try reflected (b.__radd__), then TypeError
}
This is why built-in types are faster than pure Python classes — their operations are C function pointers, not dictionary lookups.
import timeit
# Built-in int addition (C slot)
print(timeit.timeit("1 + 2", number=10_000_000)) # ~0.3s
# Custom class addition (dict lookup → Python call)
class MyInt:
def __init__(self, v): self.v = v
def __add__(self, other): return MyInt(self.v + other.v)
a, b = MyInt(1), MyInt(2)
print(timeit.timeit("a + b", globals={"a": a, "b": b}, number=10_000_000)) # ~3s
__slots__ vs __dict__¶
Slots store attributes compactly and skip the per-instance dict — less memory, no dynamic attrs.
import sys
class WithDict:
def __init__(self, x, y):
self.x = x
self.y = y
class WithSlots:
__slots__ = ('x', 'y')
def __init__(self, x, y):
self.x = x
self.y = y
d = WithDict(1, 2)
s = WithSlots(1, 2)
print(sys.getsizeof(d) + sys.getsizeof(d.__dict__)) # ~168 bytes
print(sys.getsizeof(s)) # ~56 bytes
# Slots are 3x more memory efficient for data-heavy classes
# Also slightly faster attribute access (array lookup vs dict lookup)
Examining CPython source patterns¶
How to read the C source to understand behavior precisely.
The CPython source is at github.com/python/cpython. Key directories:
| Path | Content |
|---|---|
Python/ceval.c | The bytecode evaluation loop |
Objects/longobject.c | Integer implementation |
Objects/listobject.c | List implementation |
Objects/dictobject.c | Dictionary implementation |
Include/object.h | PyObject struct definition |
Modules/ | Built-in C modules (math, json, etc.) |
Practice Exercises¶
- Demonstrate reference counting — create a chain of references, delete them one by one, observe
sys.getrefcountchanges. - Find the boundaries of the small integer cache by testing
isidentity from -10 to 300. - Measure the memory difference between a class with
__slots__vs without for 1 million instances. - Show that the GIL prevents multi-threaded CPU speedup by timing a CPU-bound function with 1 thread vs 4 threads.
- Compare attribute access speed for a regular class, a
__slots__class, and anamedtuple. - Read the CPython source for
list.appendatObjects/listobject.cand explain the over-allocation strategy.
💬 Discussion
Have a question about this topic? Found an error? Share your thoughts below.