Every Python object starts with a common C header:
typedef struct _object {
Py_ssize_t ob_refcnt; /* reference count */
PyTypeObject *ob_type; /* pointer to type object */
} PyObject;ob_refcnt is the reference count. Every INCREF increments it; every DECREF decrements it. When it reaches 0, tp_dealloc is called — the object is immediately freed.
This is why CPython object destruction is deterministic and fast: no GC pause, no delay. The object dies the moment the last reference is released.
| Operation | Effect |
|---|---|
x = obj |
INCREF on obj |
del x |
DECREF on obj |
| Function argument passed | INCREF |
| Function returns | DECREF on local variables |
| List append / dict insert | INCREF |
| List remove / dict delete | DECREF |
x = other_obj (rebind) |
DECREF on old obj, INCREF on other_obj |
Object A: ob_refcnt = 1 (referenced by B.attr)
Object B: ob_refcnt = 1 (referenced by A.attr)
If no external names point to A or B, they're unreachable. But neither refcount reaches 0 — so they're never freed. This is a reference cycle memory leak.
CPython's solution (added in 2.0): a generational garbage collector (GC) that runs periodically and finds unreachable cycles.
Objects are tracked in 3 generations:
- Generation 0: new objects (checked most frequently)
- Generation 1: survived one GC round
- Generation 2: long-lived objects (checked rarely)
The GC scans all objects in a generation, counts references that come from within the generation, and finds objects whose external references have dropped to 0. These are the unreachable cyclic garbage.
import gc
gc.get_threshold() # (700, 10, 10)
# Gen 0 collected after 700 new objects allocated since last collection
# Gen 1 collected after 10 gen-0 collections
# Gen 2 collected after 10 gen-1 collectionsdel x does not delete the object. It:
- Removes the binding of name
xfrom the current namespace - Calls
DECREFon the object - If the refcount hits 0 → object is destroyed
a = [1, 2, 3]
b = a
del a # DECREF: refcount 2 → 1. Object still alive.
del b # DECREF: refcount 1 → 0. Object destroyed NOW.CPython maintains interning tables for:
- Small integers: -5 to 256 — permanently cached, always the same object
- Strings: identifier-like strings interned at compile time; others optionally via
sys.intern() - Tuples:
tuple(t)wheretis already a tuple returns the same object (no copy) - frozenset:
fs.copy()returns the same object — a documented "harmless lie"
# Integer interning:
a = 256; b = 256; a is b # True (cached)
a = 257; b = 257; a is b # False (not cached)
# String interning:
s = sys.intern("my string") # force interningWhy interning is safe for immutables: Two equal immutable objects can safely share identity because their value will never change. If a == b for immutables, making a is b is a valid optimization with no observable behavioral difference.
A weak reference holds a reference to an object but does NOT increment its refcount. The object can be garbage collected even while a weak ref exists — when that happens, the weak ref becomes "dead" (returns None).
import weakref
cache: dict[str, weakref.ref] = {}
obj = SomeExpensiveObject()
cache["key"] = weakref.ref(obj)
# Later:
retrieved = cache["key"]() # call the ref to get the object
if retrieved is None:
# Object was garbage collected — rebuild it
...Use cases:
- Caches that don't prevent GC of cached objects
- Observer patterns — listeners don't keep subjects alive
weakref.WeakValueDictionary— values automatically removed when collected
You can customize copy behavior by implementing:
class MyClass:
def __copy__(self):
# return a shallow copy
cls = self.__class__
result = cls.__new__(cls)
result.__dict__.update(self.__dict__)
return result
def __deepcopy__(self, memo: dict):
# memo maps id(original) → copy, prevents cycles
cls = self.__class__
result = cls.__new__(cls)
memo[id(self)] = result # register BEFORE recursing to handle cycles
for k, v in self.__dict__.items():
setattr(result, k, copy.deepcopy(v, memo))
return resultThe memo dict is critical — it's what prevents infinite loops when deepcopying cyclic structures.