~/blog/finding-a-memory-leak.md

Finding a Memory Leak in a Long-Running Service

· #python #debugging

A worker process serving a scraping API was restarting itself every six hours. Not crashing — the container orchestrator’s memory limit was killing it because RSS had climbed from 180MB at boot to 2GB. Nothing in the logs. No exceptions. Just a slow, steady climb, like the process was holding its breath.

That’s the shape of most Python memory leaks: nothing dramatic, no stack trace pointing at a culprit, just a number that goes up and never comes back down. Finding the cause is not clever, it’s methodical — you take snapshots and compare them.

Confirm it’s actually growing, not just fragmented

Before hunting, rule out the boring explanation: Python’s allocator doesn’t always return freed memory to the OS, so RSS can look like a leak when it’s really fragmentation. tracemalloc sidesteps that question entirely because it tracks Python-level allocations, not what the OS reports — if tracemalloc’s total keeps growing across snapshots, something is holding references, full stop.

import tracemalloc

tracemalloc.start()
# ... let the service run for a while ...
snapshot1 = tracemalloc.take_snapshot()

Take a second snapshot after another stretch of normal traffic, then diff them:

snapshot2 = tracemalloc.take_snapshot()
top_diffs = snapshot2.compare_to(snapshot1, "lineno")

for stat in top_diffs[:10]:
    print(stat)
parser.py:44: size=812 KiB (+780 KiB), count=9821 (+9800)
cache.py:19: size=210 KiB (+4 KiB), count=3102 (+11)
requests/models.py:899: size=98 KiB (+1 KiB), count=210 (+2)

One line is doing almost all the growing. parser.py:44, +780 KiB and +9800 objects between two snapshots taken twenty minutes apart, while request volume in that window was roughly constant. That’s the signal — not the biggest allocator overall, the biggest delta.

The line, and why it leaked

# parser.py:44
_seen_ids: dict[str, dict] = {}

def parse_item(raw: dict) -> dict:
    item = normalize(raw)
    _seen_ids[item["id"]] = item   # never evicted
    return item

A dedup cache with no eviction policy, module-level, living for the life of the process. Every unique item id it ever saw stayed in memory forever. On a service processing a constant stream of scraped items with mostly-unique ids, that dict just grows in a straight line — the exact shape of the RSS graph that paged someone at 3am.

The fix is bounding it, not removing it — the cache was doing real work (deduping retries within a batch), it just needed a lifetime:

from collections import OrderedDict

class BoundedCache(OrderedDict):
    def __init__(self, maxsize: int = 10_000):
        super().__init__()
        self.maxsize = maxsize

    def __setitem__(self, key, value):
        if key in self:
            self.move_to_end(key)
        super().__setitem__(key, value)
        if len(self) > self.maxsize:
            self.popitem(last=False)  # evict oldest

_seen_ids = BoundedCache(maxsize=10_000)

Objects that won’t die: gc as a second opinion

tracemalloc finds allocation sites. Sometimes you need to know what’s still alive right now and why. gc.get_objects() plus a type count is the blunt but effective follow-up:

import gc
from collections import Counter

gc.collect()
counts = Counter(type(o).__name__ for o in gc.get_objects())
for name, n in counts.most_common(10):
    print(n, name)

If dict or a specific class count is climbing across two calls to this with no corresponding drop, something is holding a reference chain to those objects that a normal garbage collection can’t break — commonly a closure over self registered as a callback and never deregistered, or an event listener that outlives the object it was attached to.

What I learned

A leak almost never announces itself. It shows up as an infra symptom — a restart schedule, an OOM kill, a memory graph with a slope instead of a plateau — days before anyone connects it to a code change. The discipline that actually finds it is boring on purpose: snapshot, change nothing, snapshot again, diff, and trust the delta over your guess about which line “seems suspicious.” The line that grows is rarely the line you’d have bet on.

cd .. cd ~ (back to terminal)