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:
try: FastAPI executes the code beforeyieldwhen the request starts.yield: Thedbsession is injected into your endpoint.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
finallyNo long-lived global
AsyncSessionobjects 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.
Top comments (0)