Gradient AtlasML Materials

Data science roadmap › Python for Data Science › Chapter 19

Memory Management and Garbage Collection

Object lifetime, reference behaviour and reclamation.

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:

In [1]8 lines
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:

In [2]10 lines
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:

In [3]8 lines
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:

In [4]24 lines
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:

In [5]12 lines
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:

In [6]14 lines
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():

In [7]9 lines
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:

In [8]13 lines
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:

In [9]6 lines
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:

In [10]13 lines
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:

In [11]9 lines
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.