</>CodeWithKarani

FastAPI Memory Leaks in Production: A Root-Cause Checklist

Karani GeoffreyKarani Geoffrey7 min read

It runs beautifully on your laptop. You deploy it, and over hours or days the memory graph climbs a staircase that only ever goes up, until the OOM killer takes a worker, or every worker hangs and requests just stop being answered. You search, and you find two enormous GitHub threads full of people with the same symptom and forty different "fixes", none of which is marked as the answer.

That is the trap. Those threads accumulated every unrelated cause anyone ever hit, with no maintainer stepping in to say which is which. So every blog post picks one cause, calls it "the FastAPI memory leak", and it does not fix your problem because your problem is a different one on the same list.

There is rarely a leak in FastAPI itself. What looks like a leak is almost always one of a small number of your own patterns, and you can tell them apart. This is the checklist I actually run, in order, before I am willing to blame the framework.

"Growing memory" under Gunicorn/Uvicorn is usually not a leak. Work down this list:

  1. Blocking/sync code inside async def starving the event loop and piling requests into the threadpool. The single most common cause.
  2. Unbounded run_in_threadpool queuing, which looks like memory growth because the queue of waiting work grows.
  3. Dependencies / DB sessions that outlive the request due to global or module-level scope.
  4. Confirm with tracemalloc or memray against a real worker, do not guess.
  5. --max-requests worker recycling is a stopgap to stay up, not a fix.

The symptom, stated precisely

People describe it the same way in both major issue threads:

The memory usage piles up over the time and leads to OOM

Or a variant: workers stop responding entirely, health checks fail, and a restart fixes it for a while. Both descriptions cover several unrelated root causes. The job of this article is to make you stop treating them as one bug.

Memory climbs, never drops first question, not "is FastAPI leaking?" Blocking call inside async def? threadpool backs up -> looks like a leak Object outlives request? global session / cache / dependency Fix: move it off the loop def endpoint, or run_in_threadpool w/ limit Fix: scope it per-request yield dependency, close in finally
Two branches cover most cases. Confirm which one you are on before you change anything.

Cause 1: blocking code inside async def

This is the number one cause and it does not present as "blocking", it presents as memory growth and hung workers. When you declare an endpoint async def, it runs on the event loop. If inside it you call something synchronous and slow, a requests.get(), a synchronous DB driver, a CPU-heavy loop, a time.sleep(), you block the entire loop. That single worker cannot service any other request while it waits.

# WRONG: blocks the event loop for the whole request duration
@app.get("/report")
async def report():
    data = requests.get("https://slow-upstream/api")   # sync, blocking
    return heavy_cpu_transform(data.json())             # also blocking

Under load, requests arrive faster than the blocked loop can clear them. They queue. Each queued request and its context holds memory. The graph climbs. It looks exactly like a leak, but it is backpressure, and the memory is released only if the flood stops. There are two correct fixes:

# Option A: make the endpoint sync. FastAPI runs def endpoints
# in a threadpool, off the event loop, so blocking is contained.
@app.get("/report")
def report():
    data = requests.get("https://slow-upstream/api")
    return heavy_cpu_transform(data.json())

# Option B: keep async, but use async I/O and offload CPU work.
@app.get("/report")
async def report():
    async with httpx.AsyncClient() as client:
        data = (await client.get("https://slow-upstream/api")).json()
    return await run_in_threadpool(heavy_cpu_transform, data)

The rule: if the body of an async def ever blocks, either it should not be async def, or the blocking part must move off the loop. This is the same class of problem as QueuePool limit reached under FastAPI load, where blocking DB calls exhaust a pool that a bigger pool would not save.

Cause 2: unbounded threadpool queuing

The fix for Cause 1, pushing work to the threadpool (which is what a def endpoint does implicitly), has its own failure mode. Starlette's threadpool has a bounded number of threads but the queue of work waiting for a thread is effectively unbounded. If work arrives faster than the pool drains it, the backlog of pending tasks grows without limit, and every pending task holds its request payload and closures in memory.

So you "fixed" the leak by moving blocking work to threads, and now the leak is the queue of threaded work. The symptom is identical. The fix is to bound the concurrency, not to keep widening the pool:

import anyio

limiter = anyio.CapacityLimiter(20)   # cap concurrent heavy tasks

@app.get("/report")
async def report():
    async with limiter:
        return await run_in_threadpool(heavy_cpu_transform, data)

With a limiter, excess requests wait quickly and cheaply or are rejected, instead of silently accumulating in a queue that eats RAM until the OOM killer arrives. If you would rather shed load than queue it, return a 503 when the limiter is saturated.

Cause 3: objects that outlive the request

The genuine slow leak, as opposed to backpressure, is usually an object created per request that is never released, because something at a wider scope still references it. The classic offenders are database sessions and clients created at module level, or a dependency that appends to a global list or cache without bound.

# WRONG: session created once, reused, accumulates identity-mapped
# objects and never expires them
session = SessionLocal()   # module scope

@app.get("/items")
def items():
    return session.query(Item).all()   # session grows forever

The fix is a per-request dependency that is created and torn down inside the request, using yield so the teardown runs even on error:

def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()          # released at end of every request

@app.get("/items")
def items(db: Session = Depends(get_db)):
    return db.query(Item).all()

Also audit any module-level lru_cache, in-memory dict cache, or list you append to per request. An unbounded cache is not a leak in the language sense, every object is reachable, but it grows without limit and OOMs you just the same. Bound it or give it a TTL.

Confirm it: tracemalloc and memray on a live worker

Do not fix by guessing. Attach a profiler to a worker under load and let the numbers tell you which cause you have. For a first pass, Python's built-in tracemalloc needs no dependencies:

import tracemalloc, gc
tracemalloc.start()

@app.get("/debug/mem")
def mem():
    gc.collect()
    snap = tracemalloc.take_snapshot()
    top = snap.statistics("lineno")[:15]
    return {"top": [str(s) for s in top]}

Hit that endpoint, drive traffic for a while, hit it again, and compare. The line allocating the growing memory will float to the top. For deeper analysis, memray can track a running process and produce a flame graph of allocations, which is far better at distinguishing "steadily growing" from "spiky but released". Run it against one worker in staging under a realistic load, never against a single manual request, because these bugs only appear under concurrency.

The stopgap that is not a fix: --max-requests

Gunicorn's worker recycling restarts a worker after it has handled a set number of requests, which caps how much any single worker can grow:

gunicorn app:app -k uvicorn.workers.UvicornWorker \
  --workers 4 --max-requests 1000 --max-requests-jitter 100

This keeps you alive. It does not fix anything. It is a bucket under a leaking pipe, emptied on a timer. Use it to stop paging yourself at 3am while you find the real cause, and the jitter matters so all four workers do not recycle at the same instant and drop a burst of traffic. But if --max-requests is your entire mitigation six months later, you never diagnosed the leak, you just scheduled around it. This is the same honesty I insist on about Celery's worker memory growth: recycling is management, not repair.

A reproduction recipe, before you deploy the fix

You cannot know you fixed a leak you cannot reproduce. Build a small load test that mirrors the endpoint you suspect, and watch RSS over a few minutes:

# drive the suspect endpoint hard and watch worker memory
hey -z 3m -c 50 https://staging/report &
watch -n 5 'ps -o rss,cmd -p $(pgrep -f uvicorn) | sort -n'

If RSS climbs and does not recover after the load stops, you have backpressure or a true leak, and the profiler will say which. If it climbs during load and drops back after, that is normal working memory, not a leak, and you should stop optimising it. Reproduce, fix, then run the same test to prove the curve flattened.

What people get wrong

  • "It is a FastAPI leak." Almost never. It is your blocking call, your unbounded queue, or your global object. Rule those out first.
  • Making every endpoint async def because async is faster. An async def full of blocking calls is slower and more fragile than a plain def. Use def when your work is synchronous.
  • Widening the threadpool or the DB pool to "fix" growth. A bigger pool delays the OOM, it does not prevent it, and it hides the real backpressure.
  • Profiling a single request locally. These bugs are concurrency bugs. One request never reproduces them. You must apply load.
  • Shipping --max-requests and calling it done. That is a mask over the symptom. Keep it, but keep diagnosing.

When it is still broken

  1. Memory grows even with a bounded pool and per-request sessions. Profile with memray under load and look for a C-extension allocation, native libraries like some TLS stacks have their own leaks, one of which I documented in the asyncio SSL memory leak.
  2. Workers hang rather than grow. That is loop starvation, Cause 1. Find the blocking call, do not add workers.
  3. Growth only under specific routes. Good, you have narrowed it. Bisect the route's dependencies and background tasks.
  4. OOM killer takes the process cleanly. Understand what actually happened first, the kernel side is covered in what really happens when your server runs out of RAM.

The framework is almost never the culprit. The discipline is to name which of these causes you have, prove it with a profiler under load, fix that one, and re-run the load test. That is slower than pasting the fix from a GitHub comment, and it is the only thing that actually makes the graph stop climbing.

Frequently asked questions

Why does my FastAPI app leak memory in production but not locally?
Because the usual causes are concurrency bugs that only appear under load. The most common is a blocking or synchronous call inside an async def endpoint, which stalls the event loop so requests queue up and hold memory. Locally you send one request at a time, so nothing queues. Reproduce it with a load test before you try to fix it.
Is 'The memory usage piles up over the time and leads to OOM' a bug in FastAPI?
Almost never. That symptom in FastAPI apps behind Gunicorn or Uvicorn is usually your own code: blocking work inside async def, unbounded run_in_threadpool queuing, or a database session or cache scoped wider than the request so objects never get released. Profile with tracemalloc or memray under load to identify which one before blaming the framework.
Does Gunicorn --max-requests fix a FastAPI memory leak?
No, it only manages the symptom. --max-requests recycles a worker after a set number of requests, capping how large it can grow, which keeps you online. It does not remove the underlying blocking call, unbounded queue, or leaked object. Use it as a stopgap with --max-requests-jitter so workers do not all recycle at once, but keep diagnosing the real cause.
Should I make all my FastAPI endpoints async def?
No. Use async def only when the endpoint does asynchronous I/O. If the body contains blocking calls such as synchronous HTTP requests, a sync database driver, or heavy CPU work, either declare it as a plain def, which FastAPI runs in a threadpool off the event loop, or move the blocking part off the loop with run_in_threadpool. A blocking async def is slower and more fragile than a sync def.
#FastAPI#Uvicorn#Gunicorn#Memory Leak#asyncio
Keep reading

Related articles