Frontend Labs
⋯

26. Memory: reference counting, the cycle collector, and __slots__

26.1 Every object is a counted heap allocation

Every CPython object starts with a header: a reference count and a pointer to its type. When a name, container slot, or argument refers to the object, the count goes up; when the reference goes away, it goes down; at zero, the object is freed immediately.

import sys

x = []
print(sys.getrefcount(x))     # 2: `x` plus the temporary argument reference
y = x
print(sys.getrefcount(x))     # 3
del y
print(sys.getrefcount(x))     # 2

That immediacy is why this works without with:

def write_report():
    f = open("report.txt", "w")
    f.write("done")
# f's refcount hits zero here; the file is flushed and closed at once (in CPython)

Do not rely on it. It is a CPython implementation detail (PyPy has a tracing GC), and it fails as soon as anything else holds a reference, for example a traceback that keeps the frame alive. Use with.

Across the series: reference counting is Rust's Rc/Arc applied to every object (Rust §22), done by the interpreter instead of the programmer. Go uses a tracing GC (Go §27).

26.2 Cycles and the cyclic garbage collector

Reference counting cannot free a cycle: two objects that refer to each other keep each other's count above zero. CPython adds a cycle collector that periodically scans container objects for groups only reachable from each other:

import gc

class Node:
    def __init__(self):
        self.other = None

a, b = Node(), Node()
a.other, b.other = b, a       # a cycle
del a, b                      # refcounts are 1, not 0: not freed yet
print(gc.collect())           # >= 2: the collector found and freed the cycle

The collector is generational (new objects are scanned more often) and, in 3.14, incremental, which shortens its pauses. For most programs it is invisible. Two cases where it is not:

  • Large heaps of long-lived objects (a web server that loaded a big model or cache). Each full collection walks all of them. Instagram famously disabled the GC, then switched to gc.freeze() after start-up to move the start-up objects out of the collector's sight.
  • Forking workers. Updating a refcount writes to the object's memory page, so after fork() merely reading objects in a child copies the pages (copy-on-write, OS Ch 6). A "shared" 4 GB dataset slowly becomes 4 GB per worker. gc.freeze() before forking reduces this; storing data in NumPy arrays or shared memory (Ch 37) avoids it.

26.3 How big is an object?

import sys
from dataclasses import dataclass

print(sys.getsizeof(1), sys.getsizeof(1.0), sys.getsizeof(""), sys.getsizeof([]))
# 28 24 41 56  (bytes; 64-bit CPython)

class P:
    def __init__(self, x, y): self.x, self.y = x, y

@dataclass(slots=True)
class S:
    x: int
    y: int

import tracemalloc
tracemalloc.start()
ps = [P(i, i) for i in range(100_000)]
p_mem = tracemalloc.get_traced_memory()[0]
del ps
tracemalloc.reset_peak()
tracemalloc.stop(); tracemalloc.start()
ss = [S(i, i) for i in range(100_000)]
s_mem = tracemalloc.get_traced_memory()[0]
print(f"dict-based: {p_mem / 1e6:.1f} MB, slots: {s_mem / 1e6:.1f} MB")

__slots__ (or @dataclass(slots=True)) removes the per-instance __dict__ and stores attributes in fixed positions. For millions of small objects it saves 40–60% of memory and speeds up attribute access. Since 3.12–3.13, regular instances are also more compact than they used to be, so the gap is smaller than old blog posts claim. Measure.

26.4 Finding memory leaks

Python "leaks" are almost always references you forgot about: a growing module-level list or dict, an unbounded cache (@cache on a method, Ch 14.3), callbacks registered and never removed, or exceptions stored with their tracebacks (which keep every frame's locals alive).

import tracemalloc

tracemalloc.start(25)
snap1 = tracemalloc.take_snapshot()
leak = [bytes(1000) for _ in range(10_000)]     # pretend this grew over hours
snap2 = tracemalloc.take_snapshot()
for stat in snap2.compare_to(snap1, "lineno")[:3]:
    print(stat)                                 # file:line  size=+10.x MiB count=+10000

weakref lets you refer to an object without keeping it alive, which is the right tool for caches keyed by objects and for observer lists:

import weakref

class Model: pass
m = Model()
registry = weakref.WeakValueDictionary()
registry["current"] = m
print("current" in registry)   # True
del m
print("current" in registry)   # False: the entry disappeared with the object

Generated from real-life-python-guide.md · 8 Oct 2026 16:36