Finding Memory Leaks with tracemalloc

Once a leak is established — memory rising and live object count rising with it — the next question is which line is responsible, and tracemalloc answers it directly by comparing two snapshots of allocation state. This page covers taking the snapshots correctly, reading the diff, and the step after the line is found, which is identifying what holds the reference. It is a task article under memory profiling and leak detection, part of the Python profiling and performance observability section.

What the diff is comparing Two snapshots of allocation state are drawn as sets of entries, each entry recording an allocating location, a block count and a total size. The baseline is taken after the process is warm. A bounded workload of five thousand requests runs, a collection is forced so that anything merely unreferenced is freed, and a second snapshot is taken. The comparison subtracts the baseline from the current state per location, producing a ranked list of lines by how much more memory they account for than before. One line dominates, having added fifty thousand blocks, while the remaining entries show small differences consistent with ordinary variation. The note added is that the dominant line is where the memory was allocated, which is not necessarily where the reference that retains it lives. baseline, workload, collect, compare baseline cache.py:31 · 12 blocks serializers.py:88 · 591 logging:1620 · 32 5 000 requests then gc.collect() so what remains is genuinely retained current cache.py:31 · 50 033 blocks serializers.py:88 · 902 logging:1620 · 44 difference: cache.py:31 grew by 50 021 blocks — ten per request, never removed everything else is within ordinary variation this is where the memory was allocated — not necessarily where the reference that keeps it lives
The diff names the allocating line, which is usually enough. When it is not, the reason is that allocation and retention happen in different places.

Prerequisites

tracemalloc is in the standard library. A referrer inspector is useful for the step after the diff.

pip install "objgraph>=3.6.0,<4.0.0"

Implementation

Step 1 — Start tracing with a useful frame depth. The default depth of one records only the line that called the allocator, which in practice is inside a library and tells you nothing. A depth of twenty-five reaches application code from almost any library allocation, at a cost in both time and the memory tracemalloc itself uses to store the traces.

import tracemalloc
tracemalloc.start(25)

Step 2 — Take the baseline once the process is warm. A snapshot taken immediately after start is dominated by imports, module-level constants and framework initialisation, all of which grow once and never again. Letting the process serve a few hundred requests first means the difference describes steady-state behaviour rather than startup.

warm_up(requests=500)
baseline = tracemalloc.take_snapshot()

Step 3 — Run a bounded, repeatable workload. A known number of operations turns the result from "it grew by eighty megabytes" into "it retains seventeen kilobytes per request", which is the figure that identifies the problem and predicts when the process will hit its limit.

serve_requests(count=5_000)

Step 4 — Collect before the second snapshot. Without a collection, the difference includes objects that are unreferenced but not yet freed, which appear as growth and are not. Forcing a full collection first means everything remaining is genuinely retained by something.

import gc
gc.collect()
current = tracemalloc.take_snapshot()

Step 5 — Compare by line, then widen to traceback. Line grouping ranks allocating locations, which is the right first view. When the top entry is inside a library, switching to traceback grouping shows the chain that reached it, and the application frame in that chain is the call site to fix.

print("--- by line ---")
for stat in current.compare_to(baseline, "lineno")[:5]:
    print(f"{stat.size_diff/1024:9.1f} KiB  {stat.count_diff:+7d}  {stat}")

print("--- worst entry, full traceback ---")
top = current.compare_to(baseline, "traceback")[0]
for line in top.traceback.format():
    print("   ", line)

Expected Output: the line, and then the caller responsible for reaching it.

--- by line ---
  84210.4 KiB   +50021  app/cache.py:31: size=84.4 MiB (+84.2 MiB), count=50021
    412.9 KiB     +311  app/serializers.py:88: size=1.2 MiB (+412 KiB), count=902
     31.0 KiB      +12  logging/__init__.py:1620: size=88 KiB (+31 KiB), count=44
--- worst entry, full traceback ---
    File "app/views.py", line 142
      pricing = resolve_pricing(order, tenant)
    File "app/pricing.py", line 77
      return _memo.setdefault(key, compute(order, tenant))
    File "app/cache.py", line 31
      self._entries[key] = value

Ten entries retained per request, keyed on something request-specific, in a dictionary with no eviction. The diff named cache.py:31; the traceback named pricing.py:77, which is where the fix goes.

Step 6 — Express the result per operation. Dividing the growth by the workload size gives a number that both confirms the diagnosis and predicts behaviour: seventeen kilobytes per request against a five hundred megabyte headroom is roughly thirty thousand requests before the limit, which either matches the observed restart interval or means something else is also going on.

Why the frame depth matters The same allocation is reported at two different frame depths. At a depth of one, the report names the line inside the caching module where the dictionary assignment happens, which is generic code called from dozens of places and gives no indication which of them is responsible. At a depth of twenty-five, the report includes the full chain from the request handler through the pricing module to the same assignment, so the responsible call site is visible immediately. The cost is noted alongside: storing twenty-five frames per allocation uses more memory and more time than storing one, which is why the depth is a parameter rather than always being large. the same allocation, two frame depths depth 1 cache.py:31 — called from everywhere no idea which caller depth 25 views.py:142 — handler pricing.py:77 — the responsible call site cache.py:31 — where the memory is allocated the fix goes here the depth costs memory and time per allocation which is why it is a parameter — deep enough to reach your code, and no deeper
A depth of one names a line inside a library. The useful depth is whatever reaches the first frame you wrote.

After the line: finding what holds the reference

The diff says where the memory was allocated. A leak is defined by what keeps it alive, and those are the same place only some of the time. When the traceback does not make the retainer obvious, one more step identifies it.

Take a sample of the leaked objects and walk backwards through whatever references them. In almost every real case the answer appears within two or three hops and is one of the same three structures: a dictionary at module scope, a list being appended to, or a closure held by something long-lived such as a signal handler, an event subscription or a cached partial function.

import gc, objgraph

objgraph.show_growth(limit=5)                 # which types grew
sample = objgraph.by_type("PricingResult")[-1]
objgraph.show_backrefs([sample], max_depth=4, filename="/tmp/refs.png")

What to look for in the resulting graph is any edge from a long-lived object into the sample. A request-scoped object referenced only by other request-scoped objects is not leaked; it is simply still in flight. The leak is the edge that crosses from something that lives for the process's lifetime into something that should have lived for one request.

Two patterns deserve specific mention because they are easy to miss. A closure captures its enclosing scope entirely, so a callback defined inside a request handler and registered on a long-lived object retains everything that handler had in scope — including the request body and any rows it fetched. And a default argument evaluated once at function definition time, if it is a mutable container, becomes a permanent accumulator that is invisible at the call site.

Doing this against production safely

The measurement above is easiest in a reproduction, and sometimes the leak only appears under production traffic. Three arrangements make a production measurement safe enough to do.

One instance, out of rotation or with reduced weight. Tracing every allocation costs real throughput, and the cost is paid by whichever requests that instance serves. Routing a small share of traffic to one instance with tracing enabled bounds the impact and still exercises the real code paths with real data shapes — which is often why the leak does not reproduce locally.

A trigger rather than a restart. Starting and stopping tracing through a protected internal endpoint, or a signal handler, means the instance does not need restarting to begin measuring, and the baseline can be taken after it is warm. A restart would reset exactly the state that makes the leak visible.

Write the snapshots to disk and analyse elsewhere. Snapshots can be dumped to a file and loaded in a separate process for comparison, so the analysis — which is itself memory-intensive — does not happen inside the service being measured. This also means the snapshots survive if the instance is recycled before anyone looks at them.

import signal, tracemalloc

def _toggle(signum, frame):
    if tracemalloc.is_tracing():
        tracemalloc.take_snapshot().dump(f"/tmp/snap-{int(time.time())}.pickle")
        tracemalloc.stop()
    else:
        tracemalloc.start(25)

signal.signal(signal.SIGUSR2, _toggle)
A leak hunt in four steps Four steps for finding a Python memory leak with tracemalloc. First, start tracing early with enough frames, for example 25, so allocation sites include the calling code. Second, take a snapshot after warm-up, when caches have filled. Third, run the suspected workload and take a second snapshot. Fourth, compare the snapshots grouped by traceback and read the top entries, which show where memory was allocated and not released. The note says the warm-up snapshot matters, because comparing against a cold start reports every cache as a leak. finding the allocation site of a leak 1 · start tracemalloc.start(25) early in the process 2 · baseline snapshot after warm-up caches already full 3 · workload run the suspect path second snapshot 4 · compare compare_to(..., 'traceback') read the top entries compare against a cold start and every cache looks like a leak
The comparison is only as good as its baseline. Take it after warm-up, so only real growth remains.

Configuration options

Parameter Value Effect
tracemalloc.start(nframes) 25 deep enough to reach application code
Baseline timing after warm-up excludes startup allocation
Workload a fixed count growth expressible per operation
gc.collect() before snapshot always removes unreferenced noise
compare_to grouping lineno, then traceback location first, then responsibility
Filters exclude the standard library trims unrelated entries
Duration one bounded window the cost is real; turn it off afterwards

Verification

Confirm the fix by repeating the measurement, which is the only evidence that the change removed the leak rather than moving it.

import gc, tracemalloc

def retained_per_operation(work, iterations: int = 2000) -> float:
    gc.collect()
    tracemalloc.start(15)
    before = tracemalloc.take_snapshot()
    for _ in range(iterations):
        work()
    gc.collect()
    after = tracemalloc.take_snapshot()
    total = sum(s.size_diff for s in after.compare_to(before, "lineno"))
    tracemalloc.stop()
    return total / iterations

print(f"{retained_per_operation(handle_request):.0f} bytes retained per request")

Expected Output: a figure near zero after the fix, against a substantial one before it.

before   17_240 bytes retained per request
after        18 bytes retained per request

Eighteen bytes per request is ordinary noise from caches that legitimately grow and settle. A figure that is smaller but still clearly positive usually means a second, smaller leak that the first one was masking.

Common mistakes

A frame depth of one. Error signature: a report naming a line inside a standard library container. Root cause: the default depth records only the immediate allocator call. Remediation: start with enough frames to reach application code.

No collection before the second snapshot. Error signature: apparent growth that disappears on a second run. Root cause: unreferenced objects not yet freed counted as retained. Remediation: force a collection first.

Baseline taken at startup. Error signature: a diff dominated by imports and framework initialisation. Root cause: the snapshot captured one-time allocation. Remediation: warm the process before the baseline.

Grouping by type instead of by line. Error signature: a conclusion that the leak is dictionaries. Root cause: every Python program's memory is mostly dictionaries. Remediation: group by line, and widen to traceback when the line is generic.

Measuring a leak that only exists in production, locally. Error signature: a clean reproduction and a service that still grows. Root cause: the leak depends on data shapes, tenants or code paths that local traffic never exercises. Remediation: measure on one production instance with a trigger, as described above.

Leaving it enabled. Error signature: a service that stayed slow after the investigation ended. Root cause: tracing left on, recording a stack per allocation. Remediation: stop it explicitly, and prefer a bounded window from the start.

Frequently Asked Questions

How many frames should tracemalloc keep?

Enough to reach your own code from a library allocation site. One frame names the line inside the library, which is rarely useful; ten to twenty-five reaches the application's call site in almost every case. The memory and time cost rises with the depth, so twenty-five is a practical ceiling.

Why does the diff show growth in library code I never call directly?

Because the allocation happens there — in a dictionary implementation, a parser, a serialiser — while the reason it is retained is in your code. Switching from line grouping to traceback grouping shows the chain that led to the allocation, which is what identifies the responsible call site.

Does tracemalloc see memory allocated by C extensions?

Only when the extension uses Python's allocator, which well-behaved ones do for Python objects. Memory allocated with the system allocator directly — common for large buffers in numeric and compression libraries — is invisible to it and shows up only in resident memory.

Can I run it continuously?

Not comfortably. It records a stack for every allocation, which costs both time and memory proportional to the allocation rate and the frame depth. Use it for a bounded window on one instance, or in a reproduction, and turn it off afterwards.