Skip to content
← All articles

The CPython object model: names, objects and why assignment never copies

Every value is a PyObject with a refcount and a type. Names bind to those objects; assignment moves a pointer, never data. Almost every aliasing bug a mid-level engineer writes is this one fact, not yet internalised.

Three bugs, all from real code review, all the same bug:

  1. A DEFAULT_CONFIG dict is dict(DEFAULT_CONFIG)-copied into two services. One of them mutates config["retries"]["max"]. Both services now retry differently.
  2. A cache keyed on user id stores the response object, which holds a reference to the model that produced it. The model is 2 GB. The cache has a maxsize of 10,000.
  3. A test fixture builds a Request, copy.copys it per test case, and the third test fails because the second one appended to a nested list.

Each of these is written by someone who knows Python well enough to ship. None of them is a syntax problem or an API problem. They are all the same conceptual gap: the belief that a name holds a value, rather than that a name refers to an object.

What a value actually is

In CPython, every value — None, 3, "hello", a list, a class, a function, a module — is a PyObject allocated on the heap. The C struct starts with two fields that every object has:

typedef struct _object {
    Py_ssize_t ob_refcnt;    /* how many references point here */
    PyTypeObject *ob_type;   /* what kind of thing this is */
} PyObject;

ob_type is what type(x) reads. ob_refcnt is how many references currently point at the object; when it reaches zero the object is deallocated immediately.

A name is an entry in a namespace — a dict for module and instance namespaces, a slot in a frame’s fast-locals array for function locals. The entry holds a pointer to a PyObject. So:

a = [1, 2, 3]
b = a

There is exactly one list. a and b are two names pointing at it, and the list’s refcount went from 1 to 2. b.append(4) mutates the one list, and a sees it, because there was never a second list to see anything different.

This is why the question “does Python pass by value or by reference?” has no good answer: the reference is passed by value. Rebinding a parameter inside a function (x = something_else) affects only the local name. Mutating the object the parameter points at (x.append(...)) is visible to every other name pointing at it.

💡A colleague writes def add_tag(tags: list[str] = []) -> list[str]: and is surprised that tags accumulate across calls. Explain what object exists where, and then explain why def add_tag(tags: list[str] | None = None) fixes it. click to reveal

The default value is evaluated once, when the def statement executes — not on each call. That single list object is stored on the function object, in add_tag.__defaults__. Every call that does not pass tags binds the parameter name to that same list, so every mutation accumulates on the one object living on the function.

The None version works because None is a genuinely immutable singleton — there is nothing to accumulate on — and the if tags is None: tags = [] line executes on each call, creating a fresh list object per call. The fix is not “None is safer”; the fix is “move the allocation from definition time to call time”.

The corollary matters more than the rule: any mutable object stored on a long-lived structure — a function’s defaults, a class attribute, a module-level constant, a functools.lru_cache entry — is shared by everyone who touches it, forever.

Immortal objects and the refcount you must not read

You can see the refcount:

import sys
a = [1, 2, 3]
sys.getrefcount(a)     # 2 -- yours, plus the temporary reference held by the argument

The extra one is always there: passing a to getrefcount creates a reference. That alone should tell you this is a diagnostic, not an API.

In 3.12 it became less useful still. PEP 683 made a set of objects immortal: None, True, False, small integers, interned strings, and static types. Their refcount is set to a sentinel value and never decremented, so the interpreter never has to write to those objects’ headers at all — which is what makes per-interpreter GILs and free-threading tractable, because writing a refcount is a write to shared memory and writes to shared memory need synchronisation.

The visible consequence:

sys.getrefcount(None)   # 3221225472 on CPython 3.14.6

That is not “three billion references to None”. It is the immortality sentinel. The documentation now says plainly: do not rely on the returned value to be accurate, other than a value of 0 or 1.

So getrefcount is for two things: confirming that an object you expected to be unreferenced is unreferenced, and confirming that something you thought you dropped is still held. It is not for arithmetic.

💡On the free-threaded build (3.13t and up), why can't a refcount be an ordinary integer increment, and what did CPython do about it? click to reveal

Because every reference operation would be a contended atomic write to a shared cache line. Refcounting touches an object’s header on every borrow — an integer that hot, written by every core, is the definition of a scalability bottleneck. Naive atomics on refcounts is roughly how you get an interpreter that gets slower as you add threads.

CPython’s answer is layered. Immortalisation (PEP 683) removes the problem entirely for the objects touched most often — None, True, small ints, interned strings, type objects. Biased reference counting gives each object an owning thread which can use cheap non-atomic increments, with a separate shared counter for everything else. Deferred reference counting skips the refcount entirely for some interpreter-internal references. And QSBR (quiescent-state-based reclamation) delays actually freeing memory until every thread has passed a safe point.

The practical consequence for your code is the one worth remembering: on the free-threaded build, “the last reference goes away, therefore the object is destroyed now“ is no longer true. Anything whose correctness depends on prompt destruction — __del__ closing a file, a __del__ returning a connection to a pool — is broken there. It was already fragile everywhere else, for reasons the next article covers.

Identity, equality and the three-line summary

a is b        # same object? compares the pointers
a == b        # equal value? calls __eq__
id(a)         # the object's address, for the lifetime of the object

id() returns a value unique among simultaneously existing objects. Once an object dies its id can be reused, which is why id() is safe as a dictionary key for a traversal (where you hold every object alive) and dangerous as a persistent identifier.

Two rules follow, and they cover most of the practical damage:

  • Immutable objects can be shared freely, so the interpreter does share them, and you must never depend on whether it did. x is y for two equal strings or ints is an implementation detail (article 11.7 has a demonstration that will bother you).
  • Mutable objects must never be shared accidentally, so every place a mutable object could be reached by two names is a place to ask “is that deliberate?”

What to actually do with this

The knowledge only pays when it changes a habit. Three that it should:

Read every assignment as a pointer move. self.items = items in a constructor does not take ownership of a list; it creates a second name for the caller’s list. If you want ownership, write self.items = list(items), and say why in the annotation: taking Sequence[str] and storing list(...) is a copy at a clearly-marked boundary.

Treat “shared” as a property of a data structure, not of a line of code. The bug is never at the mutation site; it is at the place where two owners came to share one object, which is usually a hundred lines and one function boundary away.

Be suspicious of anything long-lived that holds objects. Module-level dicts, class attributes, lru_cache, registries, observers, and default arguments are all the same shape: a reference that outlives the scope that created it. That is precisely what “memory leak” means in a garbage-collected language — not a lost pointer, but a remembered one.