Memory: Finding What Never Goes Away

The Course
•
September 19, 2026
•
8 min read

Python frees objects nothing refers to. So a “memory leak” in Python is almost always the same thing: something is referring to them, and you forgot.

The library has had one since Day 1. Today you measure it.

tracemalloc: what grew between here and here

tracemalloc records where allocations happen. Take a snapshot, do the work, take another, and compare — which is exactly what the day’s leak.py does:

import tracemalloc

tracemalloc.start()
before = tracemalloc.take_snapshot()

load(connection, ids)                      # the work under suspicion

after = tracemalloc.take_snapshot()
tracemalloc.stop()

grew = sum(stat.size_diff for stat in after.compare_to(before, "lineno"))
for stat in after.compare_to(before, "lineno")[:3]:
    print(stat)

leak.py runs that against 5000 rows, loaded one at a time, under two cache sizes — the shipped default of 128, and 10000 (effectively uncapped for this workload). On the author’s laptop:

cache_size= 10000: cache holds  5000 objects, memory grew     1701 KiB
    models.py:103                                 550 KiB
    models.py:108                                 478 KiB
    models.py:92                                  364 KiB
cache_size=   128: cache holds   128 objects, memory grew       55 KiB
    models.py:103                                  23 KiB
    models.py:92                                   17 KiB
    models.py:108                                  12 KiB

1.7 MB against 55 KB, for the same 5000 loads. The lines named are where the surviving objects were allocated — the model construction in get and the dict insert in _remember — which is the question you usually want answered: not “what is big” but “where did the things that are still alive come from”.

Run it locally

tracemalloc and cProfile need a real Python on your own machine. Download the day’s files and run python leak.py for the numbers above, and python benchmark.py for the N+1 timings. Both are the exact scripts these lessons quote; your absolute numbers will differ, the ratios less so.

A cache without a policy is a leak

The identity map was a good idea with a missing half. Day 1 introduced it so two get() calls for one row return the same object. Nothing ever removed an entry, so the map holds every row the process has ever touched, for as long as the process lives.

That is textbook: a cache needs a policy — a size, a lifetime, or both. Without one it is an ever-growing dictionary that happens to be useful.

    cache_size: ClassVar[int] = 128

    def _remember(self) -> None:
        model = type(self)
        cache = model._instances
        cache[self.id] = self
        cache.move_to_end(self.id)
        while len(cache) > model.cache_size:
            cache.popitem(last=False)          # drop the least recently used

OrderedDict.move_to_end plus popitem(last=False) is least-recently-used eviction in two lines. get() also calls move_to_end on a hit, which is what makes it LRU rather than “oldest inserted”.

And the cap changes the meaning of the identity map, which is the part to be honest about. It used to guarantee that two get()s return the same object. Now that holds only while the object is among the most recent 128, so code relying on is for a rarely-touched row will break in production and not in tests. That’s a real cost, paid to bound memory, and it belongs in the documentation rather than in a footnote.

The other policy: weak references

weakref.WeakValueDictionary keeps an entry only while something else still refers to the object. As a cache it is pleasantly self-managing — no size to tune, no eviction — and useless as a speed-up, since entries vanish the moment callers drop them. As an identity map it is often exactly right: it guarantees “one object per row, while anyone is using it”, which is the invariant that actually matters.

What an object costs

__dict__, __slots__ and the difference Python
import sys


class WithDict:
    def __init__(self, title, views):
        self.title = title
        self.views = views


class WithSlots:
    __slots__ = ("title", "views")

    def __init__(self, title, views):
        self.title = title
        self.views = views


def total_size(obj):
    """The object plus its instance dict, if it has one."""
    size = sys.getsizeof(obj)
    if hasattr(obj, "__dict__"):
        size += sys.getsizeof(obj.__dict__)
        size += sum(sys.getsizeof(key) for key in obj.__dict__)
    return size


plain = WithDict("post 1", 3)
slotted = WithSlots("post 1", 3)

print(f"with __dict__: {total_size(plain):4} bytes")
print(f"with __slots__:{total_size(slotted):4} bytes")
print(f"10,000 of each: {total_size(plain) * 10_000 / 1024:6.0f} KiB "
      f"vs {total_size(slotted) * 10_000 / 1024:6.0f} KiB")

__slots__ replaces the per-instance dictionary with a fixed array of attribute slots. For a class with millions of instances that is a real saving, and it comes with real costs: no new attributes at run time, no weak references unless you add __weakref__, and a subtle interaction with inheritance where one base class without __slots__ gives every instance a __dict__ again.

This library does not use it, and the reason is worth stating: the fields store their values in instance.__dict__ under _title, _views. Adopting __slots__ would mean declaring those private slots on every model — which is exactly the kind of bookkeeping __init_subclass__ removed on Day 2. The memory saved is a fraction of what an unbounded cache was wasting, so the bounded cache is the better fix for the same problem.

That comparison — which change buys more, for what complexity — is what measurement is for.

Exercise

A cache that knows when to forget

Implement the LRU cache the library uses, as a standalone class with the behaviour the tests demand — including the statistics that tell you whether a cache is worth having at all.

import unittest
from collections import OrderedDict


class LRUCache:
    """At most `size` entries, least recently used evicted first.

    TODO: get(key) -> value or None, put(key, value), __len__, and the
    hits/misses/evictions counters the tests read.
    """

    def __init__(self, size=3):
        self.size = size
        self.entries = OrderedDict()
        self.hits = 0
        self.misses = 0
        self.evictions = 0

    def get(self, key):
        raise NotImplementedError("get")

    def put(self, key, value):
        raise NotImplementedError("put")

    def __len__(self):
        return len(self.entries)


class Cache(unittest.TestCase):
    def test_stores_and_returns(self):
        cache = LRUCache()
        cache.put("a", 1)
        self.assertEqual(cache.get("a"), 1)

    def test_a_miss_is_none(self):
        self.assertIsNone(LRUCache().get("nothing"))

    def test_it_never_exceeds_its_size(self):
        cache = LRUCache(size=3)
        for index in range(10):
            cache.put(index, index)
        self.assertEqual(len(cache), 3)

    def test_the_least_recently_used_goes_first(self):
        cache = LRUCache(size=2)
        cache.put("a", 1)
        cache.put("b", 2)
        cache.get("a")              # "a" is now the most recently used
        cache.put("c", 3)           # evicts "b"
        self.assertEqual(cache.get("a"), 1)
        self.assertIsNone(cache.get("b"))

    def test_reading_counts_hits_and_misses(self):
        cache = LRUCache()
        cache.put("a", 1)
        cache.get("a")
        cache.get("b")
        self.assertEqual((cache.hits, cache.misses), (1, 1))

    def test_evictions_are_counted(self):
        cache = LRUCache(size=2)
        for index in range(5):
            cache.put(index, index)
        self.assertEqual(cache.evictions, 3)

    def test_overwriting_is_not_an_eviction(self):
        cache = LRUCache(size=2)
        cache.put("a", 1)
        cache.put("a", 2)
        self.assertEqual((len(cache), cache.evictions, cache.get("a")), (1, 0, 2))


unittest.TextTestRunner(verbosity=2).run(
    unittest.defaultTestLoader.loadTestsFromTestCase(Cache)
)
Hint 1: the idea

An OrderedDict remembers order and can move a key to the end — so keep the most recently used at the end and the least at the front. get moves a hit to the end; put stores, moves to the end, and evicts from the front while over size, counting each eviction. Overwriting a key that’s already there isn’t an eviction.

Hint 2: a first step

Write get first: on a hit, move_to_end(key), count it and return the value; otherwise count a miss and return None. Then put, using popitem(last=False) to drop the oldest entry.

Show one solution
    def get(self, key):
        if key not in self.entries:
            self.misses += 1
            return None
        self.hits += 1
        self.entries.move_to_end(key)
        return self.entries[key]

    def put(self, key, value):
        self.entries[key] = value
        self.entries.move_to_end(key)
        while len(self.entries) > self.size:
            self.entries.popitem(last=False)
            self.evictions += 1

move_to_end on both paths is what makes it LRU: a read is a use. Leave it out of get and you have a first-in-first-out cache, which behaves completely differently on real access patterns — and the test for it is the one with cache.get("a") in the middle.

while, not if, in put. They’re equivalent while entries go in one at a time, and only the while stays correct when someone shrinks size at run time.

The counters are the part worth copying into real code. A cache is a bet that the same things get asked for twice, and hits-versus-misses is the only way to know whether the bet is paying. A hit rate near zero means you’ve added memory use and complexity for nothing — which you cannot discover without measuring.

What you learned

  • A Python leak is an unwanted reference; tracemalloc snapshots show which lines allocated the objects that survived.
  • A cache with no eviction policy is an unbounded dictionary — measured here at 1.7 MB for 5000 rows against 55 KB with a cap.
  • Bounding a cache weakens the guarantee it offered; say so in the docs.
  • __slots__ saves real memory and constrains your design; weigh it against the alternatives rather than reaching for it.
Last updated on September 19, 2026

Was this article helpful?

Your response is saved on this device.