Memory and the Garbage Collector
Two collectors, not one
Section titled “Two collectors, not one”CPython frees memory with two separate mechanisms working together. Reference counting is the primary one: every object’s ob_refcnt (from object-model.mdx) hits zero the instant the last reference disappears, and the object is freed immediately — deterministically, not “eventually.” The cycle garbage collector (the gc module) exists only to handle the one case refcounting can never solve on its own: a group of objects that reference each other in a cycle, so each one’s count never reaches zero even though nothing outside the cycle can reach any of them.
flowchart TD
A["object's last reference dropped"] --> B{"part of a
reference cycle?"}
B -- no --> C["refcount hits 0
freed immediately"]
B -- yes --> D["refcount never hits 0
(cycle keeps it alive)"]
D --> E["gc.collect() runs
(periodically, or manually)"]
E --> F["cycle detected and freed"] Refcounting frees immediately — no cycle needed
Section titled “Refcounting frees immediately — no cycle needed”import weakref
class Tracked: def __init__(self, name): self.name = name
def make_and_drop(): t = Tracked("solo") weakref.finalize(t, print, f"finalized: {t.name} freed the instant refcount hit 0") print("before del, still alive")
make_and_drop()print("after function returns")Real output, python3.14:
before del, still alivefinalized: solo freed the instant refcount hit 0after function returnst goes out of scope when make_and_drop returns; nothing else references it; its refcount hits zero on the spot, and weakref.finalize fires before the next line of the caller runs. No garbage collector was involved — this is pure refcounting, and it is why ordinary Python objects (files closed via __del__ as a backstop, plain data objects) usually don’t need an explicit GC pass to disappear.
A cycle defeats refcounting — gc.collect() is what frees it
Section titled “A cycle defeats refcounting — gc.collect() is what frees it”import gcimport weakref
class Node: def __init__(self, name): self.name = name self.other = None
gc.disable() # so the demo below isn't collected by a background pass firstn1 = Node("n1")n2 = Node("n2")n1.other = n2n2.other = n1 # a cycle: n1 keeps n2 alive, n2 keeps n1 alive
finalized = []weakref.finalize(n1, finalized.append, "n1")weakref.finalize(n2, finalized.append, "n2")del n1, n2print("right after del, finalized so far:", finalized)
collected = gc.collect()print(f"gc.collect() returned: {collected}")print("finalized after gc.collect():", sorted(finalized))gc.enable()Real output, python3.14:
right after del, finalized so far: []gc.collect() returned: 17finalized after gc.collect(): ['n1', 'n2']del n1, n2 removes the local references, but n1.other and n2.other still reference each other — refcounting alone will never bring either to zero. gc.collect() is the only thing that notices the cycle is unreachable from outside and frees both. The 17 is the total number of objects the collector reclaimed in that pass (each Node, each instance __dict__, and other small objects created along the way) — a real, verified number for this exact snippet, not a fixed constant.
Generations and thresholds
Section titled “Generations and thresholds”import gc
print("gc.get_threshold():", gc.get_threshold())print("gc.get_count():", gc.get_count())print("number of generations:", len(gc.get_stats()))Real output, python3.14.7:
gc.get_threshold(): (2000, 10, 10)gc.get_count(): (5, 0, 0)number of generations: 3This installation has the classic 3-generation generational collector: new objects start in generation 0; an object that survives a generation-0 collection is promoted to generation 1, and surviving generation 1 promotes to generation 2. gc.get_threshold() returns how many allocations-minus-deallocations trigger a collection of each generation (2000 for gen 0 here). Worth knowing if you read older or newer version-specific advice: Python 3.14.0–3.14.4 shipped an experimental incremental collector (only two generations — young and old — collecting in small increments instead of the whole heap at once, to cut worst-case pause times). It was reverted back to the classic 3-generation collector in 3.14.5 after reports of higher memory use in production, so a 3.14.5+ interpreter (like the one that produced this output) is back to the generational scheme described here — verify against docs.python.org/3.14/whatsnew for whichever exact 3.14.x you are running, since this is the kind of detail that changed mid-cycle.
__del__ runs at the moment refcount hits zero
Section titled “__del__ runs at the moment refcount hits zero”class Noisy: tag = "?"
def __del__(self): print(f"Noisy.__del__ running for {self.tag}")
a = Noisy()a.tag = "standalone"del aprint("(the __del__ print above already happened, synchronously, at del)")Real output, python3.14:
Noisy.__del__ running for standalone(the __del__ print above already happened, synchronously, at del)__del__ is not a scheduled callback — for a non-cyclic object it runs synchronously the instant refcounting frees it (here, exactly at del a). The classic pitfall this module’s object-model.mdx companion piece leaves unstated: if __del__ raises, the exception is printed to stderr and ignored (it cannot propagate anywhere sensible); and if an object with __del__ is part of a reference cycle, the cycle collector can still free it in modern CPython (this restriction was lifted in Python 3.4, PEP 442) — but the order in which multiple such objects are finalized within one collected cycle is not guaranteed.
weakref and WeakValueDictionary
Section titled “weakref and WeakValueDictionary”A weakref references an object without keeping it alive — the reference itself doesn’t count toward ob_refcnt. WeakValueDictionary uses this to build a cache that empties itself automatically:
import weakref
class Cache: pass
cache = weakref.WeakValueDictionary()obj = Cache()cache["k"] = objprint("'k' in cache while a strong ref is alive:", "k" in cache)del objprint("'k' in cache after the only strong ref is gone:", "k" in cache)Real output, python3.14:
'k' in cache while a strong ref is alive: True'k' in cache after the only strong ref is gone: FalseThe moment obj’s last strong reference disappears, WeakValueDictionary removes the entry itself — no manual cleanup, no if key in cache and cache[key] is stale: del cache[key] bookkeeping. This is the correct primitive for a cache that must never be the thing keeping objects alive.
Real leak sources in otherwise-idiomatic code
Section titled “Real leak sources in otherwise-idiomatic code”Three patterns leak memory in Python that looks completely ordinary:
import functools
_registry = [] # module global that silently accumulates
class Service: def __init__(self, name): self.name = name _registry.append(self) # leak #1: appended, never removed
@functools.lru_cache(maxsize=None) # leak #2: caches (self, n) -> keeps self alive forever def compute(self, n): return n * 2
before = len(_registry)for i in range(3): Service(f"svc-{i}")print(f"_registry grew from {before} to {len(_registry)}, and nothing ever shrinks it")Real output, python3.14:
_registry grew from 0 to 3, and nothing ever shrinks it_registry.append(self) is a common pattern for “keep track of every instance” (a registry, an event-bus subscriber list) — and it is a leak the moment nothing ever calls the matching remove. @functools.lru_cache on compute is leak #2, covered from a different angle in this module’s index.mdx: the cache key includes self, so every distinct instance that ever calls compute is kept alive by the cache forever, whether or not _registry exists at all. Both compile clean, pass a type checker, and run correctly in every unit test that doesn’t specifically check object counts over time.
tracemalloc — measuring an allocation, not guessing
Section titled “tracemalloc — measuring an allocation, not guessing”import tracemalloc
tracemalloc.start()snap1 = tracemalloc.take_snapshot()leaky_list = [str(i) * 50 for i in range(2000)]snap2 = tracemalloc.take_snapshot()top = snap2.compare_to(snap1, "lineno")[0]print("top allocation diff:", top)tracemalloc.stop()Real output, python3.14 (path shortened; count/size are exact):
top allocation diff: .../memory_and_gc_demo.py:5: size=432 KiB (+432 KiB), count=2003 (+2003), average=221 Btracemalloc.take_snapshot() twice, compare_to, and you have exactly which line allocated what, with byte counts — no guessing, no external profiler. This is the tool to reach for the moment “I think something is leaking” needs to become “here is the line and the size.”
Why del x doesn’t shrink the process’s RSS
Section titled “Why del x doesn’t shrink the process’s RSS”Freeing a Python object returns it to CPython’s allocator (pymalloc), which does not mean the operating system sees that memory back immediately — verified here with real RSS (resident set size) readings via ps:
import subprocessimport osimport gc
def rss_kb(): out = subprocess.run(["ps", "-o", "rss=", "-p", str(os.getpid())], capture_output=True, text=True) return int(out.stdout.strip())
print("rss before:", rss_kb(), "KB")big = [bytearray(1024 * 1024) for _ in range(50)] # 50 MB of large allocationsprint("rss after 50MB alloc:", rss_kb(), "KB")del biggc.collect()print("rss after del + gc.collect():", rss_kb(), "KB")Real output, python3.14, macOS/Apple Silicon:
rss before: 16352 KBrss after 50MB alloc: 68608 KBrss after del + gc.collect(): 68608 KBThe RSS never drops after del big — the platform allocator keeps those freed pages mapped for potential reuse rather than immediately calling back to the OS to release them, which is standard behavior for most malloc implementations, not a Python-specific bug. Small objects tell a more nuanced story: pymalloc groups small allocations (under 512 bytes) into 1 MiB “arenas,” and a fully-emptied arena can be released back to the system allocator. Running the same measurement with two million small tuples instead of large bytearrays:
rss before: 16624 KBrss after 2,000,000 small tuples: 410848 KBrss after del + gc.collect(): 35408 KBHere RSS does drop substantially, because those small objects fully vacated their arenas. The honest rule verified by both runs: whether freed Python memory returns to the OS depends on allocation size and fragmentation, not on whether you called del — a single long-lived object sharing an otherwise-empty arena, or a single large allocation, can each keep memory mapped indefinitely even after every Python reference is gone.
sys.getsizeof measures one object, not what it points to
Section titled “sys.getsizeof measures one object, not what it points to”shallow = [1, 2, 3]print("sys.getsizeof(shallow):", sys.getsizeof(shallow))
nested = [[1, 2, 3] for _ in range(3)]print("sys.getsizeof(nested):", sys.getsizeof(nested), "-- does NOT count the 3 inner lists")
def deep_size(obj, seen=None): seen = seen if seen is not None else set() if id(obj) in seen: return 0 seen.add(id(obj)) size = sys.getsizeof(obj) if isinstance(obj, (list, tuple, set)): size += sum(deep_size(i, seen) for i in obj) elif isinstance(obj, dict): size += sum(deep_size(k, seen) + deep_size(v, seen) for k, v in obj.items()) return size
print("deep_size(nested):", deep_size(nested))Real output, python3.14:
sys.getsizeof(shallow): 88sys.getsizeof(nested): 88 -- does NOT count the 3 inner listsdeep_size(nested): 436sys.getsizeof on nested reports the exact same 88 bytes as shallow — it measures only the outer list’s own container overhead, never the objects it points to. The 436-byte deep_size (roughly 5x higher) is what actually reflects the memory the whole structure holds. Reaching for plain sys.getsizeof on a container of containers and reporting that number as “the size of my data” is a common, very wrong measurement.
Full runnable demo
Section titled “Full runnable demo”import gcimport sysimport tracemallocimport weakref
print("=== refcounting frees immediately ===")
class Tracked: def __init__(self, name): self.name = name
def make_and_drop(): t = Tracked("solo") weakref.finalize(t, print, f" finalized: {t.name} freed the instant refcount hit 0") print(" before del, still alive")
make_and_drop()print(" after function returns (no cycle, freed by refcounting alone)")
print("=== cycle needs the cycle GC ===")gc.disable()
class Node: def __init__(self, name): self.name = name self.other = None
n1 = Node("n1")n2 = Node("n2")n1.other = n2n2.other = n1finalized = []weakref.finalize(n1, finalized.append, "n1")weakref.finalize(n2, finalized.append, "n2")del n1, n2print(" right after del, finalized so far:", finalized)collected = gc.collect()print(f" gc.collect() returned: {collected}")print(" finalized after gc.collect():", sorted(finalized))gc.enable()
print("=== generations / thresholds ===")print(" gc.get_threshold():", gc.get_threshold())print(" number of generations:", len(gc.get_stats()))
print("=== __del__ runs synchronously ===")
class Noisy: tag = "?"
def __del__(self): print(f" Noisy.__del__ running for {self.tag}")
a = Noisy()a.tag = "standalone"del a
print("=== weakref / WeakValueDictionary ===")
class Cache: pass
cache = weakref.WeakValueDictionary()obj = Cache()cache["k"] = objprint(" 'k' in cache, strong ref alive:", "k" in cache)del objprint(" 'k' in cache, strong ref gone:", "k" in cache)
print("=== leak sources: registry + lru_cache on method ===")import functools
_registry = []
class Service: def __init__(self, name): self.name = name _registry.append(self)
@functools.lru_cache(maxsize=None) def compute(self, n): return n * 2
before = len(_registry)for i in range(3): Service(f"svc-{i}")print(f" _registry grew from {before} to {len(_registry)}, nothing ever shrinks it")
print("=== tracemalloc snapshot diff ===")tracemalloc.start()snap1 = tracemalloc.take_snapshot()leaky_list = [str(i) * 50 for i in range(2000)]snap2 = tracemalloc.take_snapshot()top = snap2.compare_to(snap1, "lineno")[0]print(" top diff line:", str(top).split("/")[-1])tracemalloc.stop()
print("=== sys.getsizeof vs deep size ===")shallow = [1, 2, 3]nested = [[1, 2, 3] for _ in range(3)]print(" sys.getsizeof(shallow):", sys.getsizeof(shallow))print(" sys.getsizeof(nested):", sys.getsizeof(nested), "(same -- shallow only)")
def deep_size(o, seen=None): seen = seen if seen is not None else set() if id(o) in seen: return 0 seen.add(id(o)) size = sys.getsizeof(o) if isinstance(o, (list, tuple, set)): size += sum(deep_size(i, seen) for i in o) elif isinstance(o, dict): size += sum(deep_size(k, seen) + deep_size(v, seen) for k, v in o.items()) return size
print(" deep_size(nested):", deep_size(nested))Loading Python runtime (first run only)…
Mental Model
Section titled “Mental Model”Two systems keep memory in check, and only one of them is usually visible: refcounting is instant, deterministic, and running on every single reference change in your program, all the time, with zero configuration; the cycle GC is a periodic, occasional backstop that exists purely to catch the one shape refcounting cannot: a loop of references with nothing from outside pointing in. Neither of them promises that freed memory reappears as available RAM to the rest of your system instantly — that is a third, separate layer (the allocator, and below it, the OS) with its own rules about batching and reuse.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| Reference counting | Deterministic, instant frees for the overwhelming majority of objects | Per-operation overhead on every increment/decrement (part of why the GIL and free-threading matter — see gil-and-free-threading.mdx) |
| The cycle GC | Catches cycles refcounting structurally cannot | Adds a periodic pause; __del__ order across a collected cycle is not guaranteed |
weakref / WeakValueDictionary | Caches and observer lists that don’t accidentally keep everything alive | One more concept to reach for deliberately; forgetting it is exactly how the leaks in this lesson happen |
tracemalloc | Real, line-level attribution of allocations — not a guess | Measurable overhead while it’s running; typically enabled only for debugging, not permanently in production |
Common Misconceptions
Section titled “Common Misconceptions”- “Python’s garbage collector frees everything, so I never leak.” The three patterns verified in this lesson — a growing registry list,
@lru_cacheon a bound method, and an ordinary reference cycle withgcdisabled or delayed — all leak in code that looks completely idiomatic and passes every functional test. - “
del xfrees the object.” It removes one reference. The object is freed only once its refcount reaches zero (usually immediately, but not if it’s part of a live cycle, and not if another reference to it exists elsewhere). - “If RSS doesn’t drop after
del, Python leaked the memory.” Verified above: freeing large allocations often leaves the OS-level allocator holding the pages for reuse (RSS stays flat), while freeing enough small objects to fully empty pymalloc arenas can return most of that memory (RSS drops substantially) — the difference is allocator and fragmentation behavior, not a leak. - “
gc.get_threshold()and the generation count are fixed facts about Python.” They changed mid-release-cycle for 3.14 itself — 3.14.0-3.14.4 shipped an experimental 2-generation incremental collector that was reverted to the classic 3-generation design in 3.14.5. Always verify against the exact version in front of you. - “An object with
__del__can never be part of a collected cycle.” True before Python 3.4; PEP 442 removed that restriction, and the cycle GC in current Python can and does collect cycles containing__del__-defining objects — it just does not guarantee the order multiple such finalizers run in.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does a class append
selfto a module-level or class-level list/dict in__init__(a registry, a subscriber list, an event bus) with no corresponding removal anywhere? That list only grows. - Is
@functools.lru_cacheor@functools.cacheapplied directly to an instance method? The cache keeps everyselfit has ever seen alive for the life of the program. - Does a “cache” use a plain
dictwhere aweakref.WeakValueDictionary(orWeakKeyDictionary) is what’s actually meant — i.e., the cache should not be the reason an object stays alive? - Does a comment or docstring claim “no memory leaks possible, Python has garbage collection” as a blanket justification for skipping resource-lifetime review?
- Does code measure “memory usage” with a single
sys.getsizeof(container)call on a container of containers, treating the shallow size as the whole picture?
🐛 Spot the bug
class EventBus: _subscribers = []
@classmethod def subscribe(cls, handler): cls._subscribers.append(handler)
@classmethod def publish(cls, event): for handler in cls._subscribers: handler(event)
class Widget: def __init__(self, name): self.name = name EventBus.subscribe(self.on_event)
def on_event(self, event): print(f"{self.name} got {event}")
for i in range(5): Widget(f"widget-{i}")
EventBus.publish("startup")Show the answer
Every Widget calls EventBus.subscribe(self.on_event) in __init__, and self.on_event is a bound method — an object that holds a reference to self. EventBus._subscribers is a class-level list that lives for the entire program; nothing in this code ever calls a matching unsubscribe. Every one of the 5 Widget instances created here is kept alive forever by EventBus._subscribers, even though nothing else in the program holds a reference to any of them after the loop ends — this is the module-global registry leak from this lesson, just spelled as an “event bus” instead of a list named _registry. The fix is either an explicit unsubscribe called from Widget.__del__ or a context manager, or storing weakref.WeakMethod(self.on_event) in _subscribers instead of the bound method itself, so a Widget going out of scope elsewhere in the program can still be collected.