19.1 Object Lifetime and References
Since everything in Python is an object (1.1.3), a variable is never a box holding a value — it's a name bound to an object living elsewhere. Assignment (2.4) just attaches another name to the same object; it never copies it. An object's lifetime runs from creation until the moment nothing references it anymore, at which point Python reclaims its memory — the mechanics of exactly how are 19.2/19.3:
a = [1, 2, 3]
b = a # b is another NAME for the SAME object - no copy happens
print(a is b) # True
print(id(a) == id(b)) # True
b.append(4)
print(a) # [1, 2, 3, 4] -> the mutation through "b" is visible through "a" too - same object
True True [1, 2, 3, 4]
19.2 Reference Counting
CPython's primary reclamation mechanism is simple: every object carries a hidden
reference count — how many references currently point to it. The count goes up on
assignment, passing as an argument, or storing in a container; it goes down when a name
is reassigned, goes out of scope, or is deleted (19.4). The instant it hits zero, the
object is freed immediately — no waiting, no background pass. sys.getrefcount()
exposes the count directly:
import sys
x = object()
print(sys.getrefcount(x)) # 2 -> the "x" binding, PLUS the temporary argument reference getrefcount() itself holds
y = x # a second name for the same object
print(sys.getrefcount(x)) # 3 -> "x", "y", and getrefcount()'s own temporary reference
del y
print(sys.getrefcount(x)) # back to 2
2 3 2
Deterministic destruction is the direct payoff — because the count hits zero at an
exact, predictable moment, __del__ (13.1's construction/destruction row) runs right
then, not at some later, unpredictable point:
class Tracked:
def __del__(self):
print("Tracked object destroyed")
t = Tracked()
print("before del")
del t # refcount drops to 0 immediately - __del__ runs RIGHT HERE, deterministically
print("after del")
before del Tracked object destroyed after del
19.3 Garbage Collection
Reference counting alone has one blind spot: a reference cycle — two or more
objects that reference each other — can leave every object in the cycle with a
non-zero count forever, even after nothing outside the cycle points to any of them.
Plain refcounting would leak this memory. The gc module's cyclic collector exists
specifically to find and break cycles like this, running periodically alongside
refcounting rather than replacing it:
import gc
class Node:
def __init__(self, name):
self.name = name
self.other = None
def __del__(self):
print(f"Node {self.name} destroyed")
gc.disable() # disable the cyclic collector so refcounting's blind spot is visible on its own
a = Node("A")
b = Node("B")
a.other = b # a references b...
b.other = a # ...and b references a - a reference CYCLE
del a
del b # both NAMES are gone, but each object is still referenced BY THE OTHER - refcount never hits 0
print("after del - nothing destroyed yet, refcounting alone can't free a cycle")
gc.enable()
gc.collect() # the cyclic collector finds the cycle and breaks it
print("after gc.collect()")
after del - nothing destroyed yet, refcounting alone can't free a cycle Node A destroyed Node B destroyed after gc.collect()
Ordinary code almost never needs to call gc.collect() itself: the cyclic collector
normally runs on its own automatically, triggered by allocation thresholds (a counter
of net new objects created since its last pass) rather than by anything the programmer
does. gc.disable() and the manual gc.collect() above exist only to pin down
exactly when collection happens, so the "before" and "after" states are visible for
this demo — not something a real program typically needs to manage by hand.
In practice — the memory leaks that actually happen in production aren't usually cycles. A long-running web server or background worker whose memory climbs steadily over days is a real, common incident pattern — but it's almost always something far simpler than an uncollected reference cycle: a global cache, an event-listener list, or a per-request log buffer that keeps growing because nothing ever removes old entries. Refcounting handles this "correctly" (nothing is unreachable, so nothing gets collected) — the bug is that a reference to old data was never dropped in the first place, which no garbage collector can fix for you.
19.4 del and Object Destruction
del (2.5 covers its syntax for names, list items, and dict keys) removes a name
binding — it does not, by itself, destroy an object. The object is only actually
freed once that was its last remaining reference. With two names for the same
object, deleting the first leaves it alive; only deleting the second actually destroys
it:
class Tracked:
def __del__(self):
print("Tracked object destroyed")
a = Tracked()
b = a # a second name for the SAME object
del a # removes the NAME "a" - the object survives, "b" still references it
print("after del a - no destruction message above")
del b # the last reference is gone - NOW the object is actually destroyed
print("after del b")
after del a - no destruction message above Tracked object destroyed after del b
Common mistake — relying on __del__ for timely cleanup. __del__ only fires when
the refcount hits zero — 19.3 just showed that a reference cycle can delay this until
the next gc.collect(), whenever that happens to run, rather than at a moment the
program controls. For resources that genuinely need deterministic cleanup (file
handles, network sockets, locks), a with block and __enter__/__exit__ (16.6) is
the reliable tool — its timing doesn't depend on reference counting at all.
19.5 Interning, Caching and Small-Object Behaviour
As a memory/speed optimization, CPython caches certain small, immutable objects instead of creating a fresh one every time an equal value is needed:
| What's cached | Range / rule |
|---|---|
| Small integers | every integer from -5 to 256 is a permanent, pre-allocated singleton |
| Some strings | short, identifier-like string literals are automatically interned |
Two variables holding a small int that happen to be equal turn out to be the same
object — is and == agree, which can make is look like a safe shortcut for value
comparison until a number falls outside the cached range:
a = 100
b = 100
print(a is b) # True -> both refer to the same cached singleton (100 is inside -5..256)
def make_thousand(): # built at runtime, in a loop - not a literal the compiler can fold
total = 0
for _ in range(1000):
total += 1
return total
x = make_thousand()
y = make_thousand()
print(x == y) # True -> equal values
print(x is y) # False -> two DISTINCT objects; 1000 is outside the small-int cache
True True False
String interning follows the same shape: identical short literals share one object,
but a string built at runtime (not typed as a literal) generally doesn't — unless
forced into the interned pool explicitly with sys.intern():
name1 = "hello"
name2 = "hello"
print(name1 is name2) # True -> identical short literals are interned automatically
built = "".join(["h", "e", "l", "l", "o"]) # the same characters, but built at runtime
print(built is name1) # False -> not automatically interned, even though built == name1
interned_built = sys.intern(built)
print(interned_built is name1) # True -> sys.intern() forces it into the same interned pool
True False True
Common mistake — using is for value comparison. Small-integer caching and string
interning above are CPython implementation details — an optimization, not a
guarantee in the Python language specification. A different interpreter (PyPy), a
different CPython version, or even the same interpreter under different conditions is
free to cache differently, or not at all. Always use == to compare values; reserve
is for identity checks (is None, is True, singleton sentinels) where being the
exact same object is actually what's being asked.
19.6 Memory Inspection and Profiling
Two stdlib tools for actually looking at memory use rather than reasoning about it
abstractly: sys.getsizeof() for one object at a time (already used to compare a
list and a generator in 14.4), and tracemalloc for tracking allocations over a
whole block of code. Per-instance overhead specifically — the __dict__ every
ordinary object carries just to hold its attributes — has its own standard fix,
covered in 10.2's __slots__: it isn't a memory-inspection tool like the two below,
but the usual first thing to reach for once inspection reveals attribute storage is the
actual cost:
print(sys.getsizeof(0)) # 28 -> size, in bytes, of a small int object
print(sys.getsizeof([])) # 56 -> size of an empty list
print(sys.getsizeof([1, 2, 3])) # 88 -> grows as contents are added
import tracemalloc
tracemalloc.start() # begin recording every allocation from this point on
data = [str(i) for i in range(10000)] # allocate a real chunk of memory
current, peak = tracemalloc.get_traced_memory()
print(current > 0, peak >= current) # True True -> the allocation above was actually tracked
tracemalloc.stop()
28 56 88 True True
Common mistake — trusting sys.getsizeof() for a container's total memory
footprint. It reports only the container's own overhead — the box itself — not the
size of whatever objects it references. A list of a few large strings reports the
same small, misleading number as a list of a few tiny ones, because the strings' own
memory isn't counted at all:
small_list = [0, 1, 2]
big_list = ["x" * 10_000 for _ in range(3)] # 3 strings, each ~10KB
print(sys.getsizeof(small_list)) # 88 -> container overhead only, for 3 small ints
print(sys.getsizeof(big_list)) # 88 -> the SAME number - same shape (3 elements), container overhead only
print(sys.getsizeof(big_list[0])) # 10049 -> the actual string memory isn't counted in getsizeof(big_list) at all
88 88 10041
In practice — this is exactly what tracemalloc (or the third-party
memory_profiler) is for. When a production service's memory usage keeps climbing
and sys.getsizeof() on individual objects doesn't explain it, real teams reach for
tracemalloc to take snapshots of the whole process's allocations at two points in
time and diff them — pinpointing which line of code is responsible for the growth,
instead of guessing.
19.9 Weak References
A normal reference (19.1) counts toward an object's refcount, keeping it alive. The
weakref module's ref() creates a reference that doesn't — useful for things like
caches that should never be the reason an object stays alive. Calling a weak reference
returns the live object, or None once it's actually been destroyed:
import weakref
class Resource:
def __del__(self):
print("Resource destroyed")
r = Resource()
weak_r = weakref.ref(r) # a reference that does NOT increase the refcount
print(weak_r() is r) # True -> calling it returns the real object, while it's still alive
del r # the ONLY strong reference is gone
print(weak_r()) # None -> the object was destroyed immediately; the weak ref didn't keep it alive
True Resource destroyed None
weakref.WeakValueDictionary puts that "useful for caches" claim to work: it's a
dict whose values are held weakly — an entry disappears on its own, with no manual
cleanup, the moment nothing outside the cache still holds a strong reference to that
value:
cache = weakref.WeakValueDictionary()
r2 = Resource()
cache["a"] = r2 # storing here does NOT keep r2 alive by itself - the dict's reference is weak
print("a" in cache) # True -> still alive, "r2" itself is a real strong reference
del r2 # the only STRONG reference is gone
print("a" in cache) # False -> the cache entry vanished on its own, no manual cleanup needed
True Resource destroyed False
In practice — real caching layers use exactly this to avoid unbounded growth. An
in-memory cache of parsed config objects, compiled templates, or computed results
often uses a WeakValueDictionary (or the weakref-based caching built into libraries
like functools) specifically so that cached entries don't keep objects alive forever
— once nothing else in the program needs a cached value, it's free to disappear on its
own, instead of the cache slowly consuming all available memory.