All writing
databases coding sqlalchemy fastapi

Fixing “QueuePool limit reached”: Debugging DB Connection Leaks in FastAPI

Ayush Kaushik 4 min read
Fixing “QueuePool limit reached”: Debugging DB Connection Leaks in FastAPI

The Nightmare Scenario

Your FastAPI app runs perfectly on your local machine. You deploy it to production. It works fine for a few hours.

Then, suddenly, requests start hanging. Your logs are flooded with this error:

Plaintext

sqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10 reached, connection timed out, timeout 30.

Or if you are using Postgres directly:

Plaintext

FATAL: sorry, too many clients already

Then your API dies. Restarting the container fixes it… for another 3 hours.

If this sounds familiar, you have a Database Connection Leak.

Early Warning Signs You’re Leaking Connections

Before the fatal QueuePool limit reached error appears, your system usually gives subtle hints:

  • Response times slowly increase over hours or days

  • Database connection count keeps rising but never drops

  • CPU usage stays normal, but throughput degrades

  • Restarting the app temporarily “fixes” the issue

If restarting your service magically resolves the problem, you are almost certainly leaking connections.

The Root Cause: “Zombie” Sessions

The most common cause of this error in FastAPI is improper Dependency Injection.

When you open a database session, you borrow a connection from the pool. You must return it when you are done. If you forget, the connection stays “checked out” forever. Eventually, the pool runs empty, and new requests wait in line until they time out.

Here is the code that usually causes this:

The Leaky Dependency (The Mistake)

Python

# BAD CODE - DO NOT USE
def get_db():
db = SessionLocal()
return db # <--- DANGER!

Why this fails: FastAPI is smart, but it can’t read your mind. If you just return db, FastAPI doesn’t know when or how to close it. The session stays open until the garbage collector runs (which might be never under load).

What makes this especially dangerous in FastAPI is concurrency.
A single leaked session in a synchronous app might go unnoticed. In an async app handling hundreds of concurrent requests, leaked sessions accumulate rapidly. Async doesn’t create the bug it amplifies it.

The Fix: The yield Pattern

To fix this, we use a Python generator with yield. This turns your dependency into a Context Manager.

The Leak-Proof Dependency

Python

# GOOD CODE
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close() # <--- This guarantees the connection goes back to the pool

How it works:

  1. try: FastAPI executes the code before yield when the request starts.

  2. yield: The db session is injected into your endpoint.

  3. finally: After the response is sent (even if your code crashed with an error!), FastAPI resumes execution here and closes the connection.

Advanced Fix: Tuning the Pool

Sometimes your code is perfect, but your traffic is just too high for the default settings.

SQLAlchemy’s default pool size is 5. If you have 10 concurrent requests, 5 will get a connection, and 5 will wait.

You can increase this in your create_engine call:

Python

engine = create_async_engine(
DATABASE_URL,
pool_size=20, # Increase baseline connections
max_overflow=10, # Allow 10 extra "burst" connections
pool_pre_ping=True # Check if connection is alive before using
)

Pro Tip: Don’t set pool_size too high! If you have 4 Uvicorn workers and set pool_size=20, you are actually opening 80 connections (4 * 20) to your database. Make sure your database (e.g., Postgres AWS RDS) can handle that math.

SQLAlchemy 2.0 Async Gotcha

When using AsyncSession, the same rules apply but violations are harder to spot. Forgetting to close an async session may not fail immediately, especially under low load.

Always ensure:

  • Sessions are created via a dependency

  • Cleanup happens in finally

  • No long-lived global AsyncSession objects exist

Async doesn’t forgive lifecycle mistakes.

The Asyncpg Trap

If you are using asyncpg directly (or via SQLAlchemy 2.0 Async), you might hit the TooManyConnectionsError.

This often happens because AsyncIO is too fast. It can spawn 1,000 tasks in milliseconds, all trying to open a connection at once.

The Solution: Use a Connection Pool (like pgbouncer) in front of your database, or strictly limit your application’s pool size to match your database’s max_connections limit.

Another Common Anti-Pattern: Session Per Query

A subtle but harmful pattern looks like this:

def get_user(user_id: int):
db = SessionLocal()
user = db.query(User).filter(User.id == user_id).first()
db.close()
return user

While this closes the session, it creates excessive churn under load. Opening and closing sessions repeatedly inside business logic defeats pooling benefits and increases latency.

Prefer a single session per request, managed by a dependency.

Conclusion

Connection leaks are almost always lifecycle bugs, not database bugs.

Before blaming Postgres, ask yourself:

  • Do I open one session per request?

  • Is it always closed using yield + finally?

  • Does my pool size match my worker count?

If you get these right, QueuePool limit reached disappears permanently not temporarily.

If you are struggling with other SQLAlchemy 2.0 migration issues, check out my guide on Fixing AsyncSession Query Errors and FastAPI Lifespan vs Startup Events.

If you are struggling with other SQLAlchemy 2.0 migration issues, check out my guide on Fixing AsyncSession Query Errors.

Working on something similar?

If you're building backend or AI systems and want a second set of senior eyes, let's talk.

Keep reading

Related articles