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.

⚡ TL;DR — Key takeaways
  • 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

SituationTypical SymptomWhy 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 rowsSudden jump after first batchGlobal dict is a GC root
Async function returning a large object without `await`Leak only under loadTask 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+

ToolOverheadWhat It Captures2024‑Specific Feature
**Memray 1.11+**~2 % (CPU)Allocation stack, free events, native C extensionsIntegrated 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 trackingAsync‑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

  1. **Define a realistic load script** – use `locust` or `hey` to simulate your peak QPS.
  2. **Run the baseline without any instrumentation** and record RSS via `psutil.Process().memory_info().rss`.
  3. **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

  1. Take the top‑growing type from the diff.
  2. Use `objgraph` to locate back‑references.
  3. 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.

Written by

’m Nilesh, a Software Development Engineer with 2+ years of experience, specializing in Go, JavaScript, Python, Docker, Kubernetes, Git, Jenkins, microservices, and system design (LLD/HLD), backed by a strong foundation in data structures and algorithms. Alongside my engineering journey, I bring 4+ years of hands-on experience in SEO, where I’ve worked extensively on content strategy, keyword research, technical SEO, and organic growth, helping products and businesses scale efficiently by aligning solid technology with search-driven performance.