Quant Memo
Core

The Python Object Model and Refcounting

Every value in Python, even a small integer, is a full object living on the heap with a reference count attached, and that count hitting zero is what triggers memory to be freed.

In C, an int is four bytes sitting wherever the compiler puts it — no metadata, no bookkeeping. In Python, even the number 5 is a full object: a chunk of heap memory holding a type pointer, a value, and a count of how many places in the program currently refer to it. This is the Python object model, and it's why Python is flexible (everything can be inspected, passed around, and garbage collected uniformly) and why it's slow for tight numeric work (every operation drags that overhead along with it).

The reference count — usually called the refcount — is the mechanism CPython uses to know when an object's memory can be reclaimed. Every time a variable, list, or data structure points at an object, its refcount goes up by one. Every time a reference goes away (variable reassigned, list cleared, function returns), the refcount goes down by one. When it hits zero, nobody in the program can reach that object anymore, so CPython frees it immediately.

Refcounting means memory is usually freed the instant it becomes unreachable, not at some unpredictable later garbage-collection pause — which is why Python programs feel like they clean up "as you go." The cost is that every assignment, function call, and container operation touches a counter, and that bookkeeping is real overhead that specialized numeric code (like NumPy arrays or Numba-compiled loops) works hard to avoid.

Watching a refcount move

object refcount=2 a ──┐ b ──┘ del b → refcount=1 del a → refcount=0 → freed
Each name pointing at the object adds one to its refcount; when the last name is removed, the count hits zero and CPython reclaims the memory on the spot.

Worked example

import sys
x = [1, 2, 3]
print(sys.getrefcount(x))   # 2 — one for x, one for the getrefcount call's own argument
y = x
print(sys.getrefcount(x))   # 3 — y now also points at the same list object
del y
print(sys.getrefcount(x))   # back to 2

Nothing was copied here — y = x made y a second name for the same list object, and the refcount recorded that a second reference exists. Only when every name pointing at that list disappears does the list's memory actually get freed. This is also why mutating a list through one name changes what every other name sees: x.append(4) and now y (before the del) would show [1, 2, 3, 4] too, since there was only ever one object.

A second example shows why small integers feel different: a = 5; b = 5 often shows a is b returning True, because CPython pre-creates and caches small integers (-5 to 256) once at startup rather than allocating a fresh object every time one is needed — an optimization that only works because integers are immutable, so sharing them is always safe.

What this means in practice

Refcounting overhead is exactly why a plain Python loop doing arithmetic is slow compared to a NumPy array operation or a Numba-compiled function: each element access on a Python list touches a full object with its refcount, while a NumPy array stores raw values contiguously with no per-element object overhead at all. It's also the reason circular references (two objects referencing each other) don't get cleaned by refcounting alone — CPython runs a separate cyclic garbage collector specifically to catch reference loops that would otherwise never hit zero.

Refcounting is not the same thing as "no garbage collector." CPython has both: refcounting handles the common case immediately, and a periodic cycle-detecting collector handles reference loops. Code that creates and breaks many circular references (common in tree or graph structures with parent-and-child pointers) can trigger real, measurable GC pauses despite refcounting's usual immediacy.

Related concepts

Practice in interviews

Further reading

  • Ramalho, Fluent Python (ch. 6, 'Object References, Mutability, and Recycling')
ShareTwitterLinkedIn