I pushed a new feature that was supposed to “just cache results for a minute”. Six hours later the pod was OOM‑killed, the replica set flapped, and PagerDuty was screaming “memory exhausted”. The logs showed nothing obvious—no exception, no traceback—just a steady rise in RSS. My first thought was “maybe the cache grew too big”, but the cache size was bounded. The real culprit was a reference cycle hidden inside a custom serializer that the GC never broke. That night taught me two things: 1️⃣ you can’t rely on “no errors” to mean “no leaks”, and 2️⃣ the right profiler turns a mysterious OOM into a single line of code you can fix.
- Understand where CPython’s GC falls short in async web workloads.
- Pick a 2024‑ready profiler (Memray, Fil, or Scalene) for low‑overhead snapshots.
- Establish a repeatable baseline under realistic load before hunting leaks.
- Watch for framework‑specific traps: Django ORM caches, FastAPI dependency scopes, and async connection pools.
- Wire memory health checks into CI/CD and APM so leaks surface before they crash prod.
Before you start: Python 3.11 or 3.12, `pip install memray==1.11.* fil==2024.5.1.* scalene==2.0* objgraph pympler`, a running Django 4.2 / FastAPI 0.104+ app behind Gunicorn 22 (WSGI) or Uvicorn 0.24 (ASGI), Redis‑py 5.0+, and access to an APM (Datadog or New Relic).
Debugging Memory Leaks in Python Web Apps
Debugging memory leaks in Python web apps requires identifying objects the garbage collector cannot reclaim. Key steps involve: establishing a memory baseline, using profilers like Memray or tracemalloc to take snapshots, diffing them to find growing object types, and inspecting reference chains. Common culprits include global caches, unclosed resources, and reference cycles.
Understanding Python Memory Management for Web Apps
How CPython’s Garbage Collector Works (and Where It Fails)
CPython uses a two‑tier system: reference counting for immediate reclamation and a cyclic GC that runs periodically. The reference counter drops to zero → object is freed instantly. The cyclic collector scans containers (lists, dicts, sets) for groups of objects that only reference each other.
*Where it fails*:
- Objects with a `__del__` method are **uncollectable** if they belong to a cycle.
- The GC only scans objects reachable from *GC roots* (module globals, stack frames, thread state). Anything unintentionally stored in a global dict or a long‑lived cache becomes a permanent root.
- In Python 3.12 the GC threshold defaults changed slightly, making some micro‑benchmarks appear “cleaner” while hiding slow‑growing cycles.
In my experience, the most common surprise is the *async task* hidden in the event loop. An `await` that never completes leaves the coroutine object dangling in the loop’s internal list, and because the loop holds a reference to the task, the reference count never hits zero.
Reference Cycles vs. Global Scope Traps
| Situation | Typical Symptom | Why GC Misses It |
|---|---|---|
| `obj_a -> obj_b -> obj_a` with `__del__` | Gradual RSS climb, no traceback | `__del__` blocks cyclic collection |
| Module‑level dict caching DB rows | Sudden jump after first batch | Global dict is a GC root |
| Async function returning a large object without `await` | Leak only under load | Task stays in loop’s pending set |
The fix is usually to break the cycle manually or replace `__del__` with context managers/`weakref.finalize`.
Web Framework‑Specific Memory Lifecycles: Django, Flask, FastAPI
- **Django 4.2**: ORM querysets are lazy. If you cache a queryset object rather than its evaluated list, the underlying SQL cursor stays alive. Django’s request‑middleware stack also holds a reference to the `request` object until the response is fully sent—so any large payload attached to `request` can linger for the whole response cycle.
- **Flask**: The application object (`app`) lives in the *global* module scope. Extensions that store state on `app` (e.g., Flask‑Cache) become roots.
- **FastAPI 0.104+**: Depends are resolved per‑request, but a singleton dependency (declared with `Depends(lambda: Singleton())`) will be cached in the global `Depends` store. If that singleton holds a heavy object, it never gets collected.
**My take:** Most docs paint these frameworks as “stateless” but in production they’re anything but. Treat any object you attach to the framework’s global registry as a potential leak source.
Essential Tools for Memory Leak Detection
objgraph & pympler for Object Relationship Mapping
`objgraph` visualizes the object graph. A quick `objgraph.show_most_common_types(limit=10)` after a load test often reveals the unexpected “str” or “dict” explosion.
# objgraph example – Python 3.12
import objgraph
import gc
gc.collect()
objgraph.show_most_common_types(limit=15)
objgraph.show_backrefs(
objgraph.by_type('MyLeakyClass')[0],
max_depth=3,
filename='leak_backrefs.png')
`pympler`’s `asizeof` gives you the *deep* size of a container, accounting for nested objects—a handy sanity check when you suspect a dict is holding giant blobs.
Using tracemalloc for Precise Allocation Tracking
`tracemalloc` is built‑in and zero‑cost until you start snapshots. The typical workflow:
# tracemalloc example – Python 3.11
import tracemalloc
import time
tracemalloc.start()
# warm‑up
time.sleep(2)
snapshot1 = tracemalloc.take_snapshot()
# run load for 30 s
time.sleep(30)
snapshot2 = tracemalloc.take_snapshot()
top_stats = snapshot2.compare_to(snapshot1, 'lineno')
for stat in top_stats[:10]:
print(stat)
The comparison shows which lines allocated the most memory between the two snapshots. In 2026 the CPython docs added a `limit` argument to `compare_to` that reduces noise from transient allocations.
Production‑Ready Profiling: Memray, Fil, and Scalene v2.0+
| Tool | Overhead | What It Captures | 2024‑Specific Feature |
|---|---|---|---|
| **Memray 1.11+** | ~2 % (CPU) | Allocation stack, free events, native C extensions | Integrated Flamegraph export, supports Python 3.12’s new thread‑local GC |
| **Fil 2024.5.1+** | <1 % (RSS) | Periodic memory‑usage snapshots, heap‑diff diffing | “Passive” mode that can be toggled via env var without restarting |
| **Scalene 2.0+** | 3–5 % | CPU + line‑level memory allocation, async task tracking | Async‑aware mode that tags coroutine owners |
**How to pick:** For local debugging, Memray’s flamegraph is priceless. For production, Fil’s “attach‑once” mode adds almost no latency and can stream snapshots to a central collector. Scalene shines when you need to correlate CPU hotspots with memory growth.
*Internal link:* For a deeper dive on wiring Memray into CI pipelines, see my [Memray for Continuous Integration tutorial](/debugging-memory-leaks-production-python-apps/).
Step‑By‑Step Debugging Workflow
Establishing a Memory Baseline Under Load
- **Define a realistic load script** – use `locust` or `hey` to simulate your peak QPS.
- **Run the baseline without any instrumentation** and record RSS via `psutil.Process().memory_info().rss`.
- **Store the numbers** (e.g., in a CSV) for later comparison.
# baseline.py – Python 3.12
import psutil, time, sys
proc = psutil.Process()
for _ in range(10):
print(f"RSS: {proc.memory_info().rss/1024/1024:.2f} MiB")
time.sleep(5)
The goal is a *stable* line graph; any upward drift signals hidden allocation.
Isolating the Leak: Using Diff Snapshots Over Time
Once you have a baseline, sprinkle `tracemalloc` snapshots or Fil’s `fil collect` calls every 30 seconds during the load test. Then run `diff`:
fil diff snapshot_0.json snapshot_10.json --output diff.html
The generated HTML visualizes which object types grew most. In one of my projects the diff highlighted a surge in `OrjsonEncoder` objects, pointing to a custom JSON response wrapper that never released its buffer.
Pinpointing the Culprit: From Suspect to Root Cause
- Take the top‑growing type from the diff.
- Use `objgraph` to locate back‑references.
- Follow the chain until you hit a global container or a `__del__` method.
If the chain ends at a `weakref.WeakValueDictionary` that you never cleared, you’ve found the leak.
Common Architectural Pitfalls & Production Gotchas
Caching Misconfigurations with Redis/Memcached Clients
Redis‑py 5.0 introduced a lazy connection pool. If you **forget to close** the pool on worker shutdown, the pool remains reachable from the module‑level client, keeping every socket open. The symptom is a gradual increase in file descriptor count (`lsof | wc -l`) and a matching RSS bump.
# good practice – Python 3.12
import redis
from contextlib import asynccontextmanager
@asynccontextmanager
async def get_redis():
client = redis.asyncio.Redis()
try:
yield client
finally:
await client.close() # closes pool
Database Connection Pool Leaks in Async Frameworks
`psycopg3`’s async pool works fine until a coroutine exits via an exception **without** releasing the connection. The pool’s internal `free` list never regains the slot, causing the pool to grow until the process hits the DB’s max‑conn limit.
# async DB usage – Python 3.11
import asyncio
import psycopg
async def fetch_user(uid):
async with psycopg.AsyncConnection.connect(dsn) as conn:
async with conn.cursor() as cur:
await cur.execute("SELECT * FROM users WHERE id = %s", (uid,))
return await cur.fetchone()
# ensure every path uses the async context manager
Third‑Party Library & C‑Extension Memory Retention
Many ML libraries (NumPy, PyTorch) allocate large native buffers. The Python GC can’t see inside them, so a stray reference to a NumPy array keeps the entire buffer alive. A 2023 Sentry study (still relevant in 2026) showed **23 %** of severe incidents stem from native extensions holding onto memory longer than expected.
**Fix:** Wrap native objects in a weakref finalizer.
# weakref finalizer – Python 3.12
import weakref, numpy as np
def release_array(arr):
print("Array freed")
# any C‑API cleanup, if needed
arr = np.arange(1_000_000)
weakref.finalize(arr, release_array, arr)
Preventative Code Patterns & Systematic Guardrails
Context Managers and `weakref` for Clean Object Lifetimes
A well‑scoped context manager guarantees cleanup even when exceptions happen. Combine it with `weakref.finalize` for C‑extension buffers.
# context manager with weakref – Python 3.11
from contextlib import contextmanager
import weakref, redis
@contextmanager
def redis_conn():
client = redis.Redis()
try:
yield client
finally:
client.close()
# ensure any native buffers are released
weakref.finalize(client, lambda: print("Redis client freed"))
Integrating Memory Health Checks into CI/CD Pipelines
Add a step that runs a short load test with `fil collect` and fails the build if RSS growth exceeds 15 % over the baseline.
# .github/workflows/memory.yml
name: Memory Leak Check
on: [push, pull_request]
jobs:
leak-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install deps
run: pip install -r requirements.txt fil
- name: Run load & collect
run: |
fil start --output baseline.json &
python -m locust -f tests/locustfile.py --headless -u 100 -r 10 --run-time 30s
fil stop --output final.json
fil diff baseline.json final.json --threshold 15
If the diff exceeds the threshold, the job aborts, preventing a leak from reaching production.
Setting Alert Thresholds with APM Tools (Datadog, New Relic)
Both Datadog and New Relic expose **process memory** as a metric. Create a monitor that triggers when the 5‑minute rolling average of `process.memory.rss` climbs > 20 % over the 24‑hour baseline. Pair the alert with a **snapshot** of the heap using Fil’s remote API.
Common Errors & Fixes
Warning: Running a profiler in production without throttling can itself cause OOM. Use Fil’s low‑overhead mode or sample only a subset of workers.
Error: “ReferenceError: weakref proxy object has been garbage collected”
*Why it happens*: A `weakref.proxy` is accessed after the original object has been finalized, often because the proxy was stored in a global dict.
*Fix*:
# safe weakref usage – Python 3.12
import weakref
class Cache:
def __init__(self):
self._store = {}
def set(self, key, value):
self._store[key] = weakref.ref(value)
def get(self, key):
ref = self._store.get(key)
if ref is None:
return None
obj = ref()
if obj is None:
del self._store[key] # clean stale entry
return obj
Error: “psycopg3 pool exhausted – cannot acquire connection”
*Why it happens*: An async coroutine raised an exception before reaching the `async with` exit, leaving the connection checked out.
*Fix*: Wrap DB calls in a helper that guarantees `await conn.release()` on any path.