Module 6: Advanced Connection Pooling
asyncpg and AsyncEngine: the driver and the FastAPI patterns
Capsule overview
In capsule 03 you saw the five general parameters of the SQLAlchemy pool. Those parameters work the same with any driver: psycopg sync, psycopg async, asyncpg. But the chosen driver changes details that will matter to you in production.
This capsule focuses specifically on asyncpg + SQLAlchemy 2.0 AsyncEngine — the canonical stack for FastAPI. You're going to see:
- Why asyncpg is the recommended driver (vs
psycopgasync). - asyncpg's statement cache: what it is, why it exists, and why it's a problem when you add PgBouncer (capsule 06).
- How
create_async_engineandasync_sessionmakerconnect, and what each relevant parameter does (expire_on_commit,connect_args). - The canonical session-per-request pattern with
Depends(get_db)in FastAPI, which is what avoids most leaks. - How to close the engine correctly when shutting down the app (lifespan handler).
By the end you'll have an app/db.py module ready for production, with the patterns that avoid the most common errors in async.
Mental model: asyncpg as a specialized engine
If SQLAlchemy is the chassis and transmission, the driver is the engine. Switching from psycopg sync to psycopg async is like going from gasoline to diesel — a different fuel, the same principle. Moving to asyncpg is like switching to a turbo engine designed specifically to run on an async circuit — faster, less overhead, but with its own maintenance (statement cache, specific configuration).
asyncpg was designed from scratch for asyncio. It's not an async wrapper over sync code — it's native async code that speaks PostgreSQL's binary protocol directly. That's why it's the fastest driver for Python async. But that performance comes with details you need to know.
Why asyncpg vs psycopg async
SQLAlchemy 2.0 supports several drivers for PostgreSQL in async:
| Driver | URL prefix | Performance | Maturity | Notes |
|---|---|---|---|---|
asyncpg | postgresql+asyncpg:// | Fastest (~30% vs psycopg) | Mature, used in production at scale | Native binary protocol. Its own statement cache. |
psycopg async (v3) | postgresql+psycopg:// | Fast | Mature | Async API added in v3. Works well if you already use psycopg elsewhere. |
psycopg2 async | (deprecated for new projects) | — | — | Don't use in new code. |
For a new FastAPI: asyncpg is the recommended default. It's used by Supabase, Litestar, official FastAPI templates.
Technical reason for the performance: asyncpg doesn't use libpq (PostgreSQL's official C library). It implements the binary protocol directly in a C extension + Python. That eliminates parsing, type conversion, and serialization overhead.
Caveat: asyncpg handles some things differently from libpq. The most relevant: prepared statements. It prepares them automatically and caches them. In setups with PgBouncer transaction mode, this breaks — capsule 06 covers the fix.
Base setup: create_async_engine in detail
# app/db.py
from sqlalchemy.ext.asyncio import (
create_async_engine,
async_sessionmaker,
AsyncSession,
)
DATABASE_URL = "postgresql+asyncpg://bookstore:bookstore@localhost:5432/bookstore"
engine = create_async_engine(
DATABASE_URL,
# Pool params (capsule 03)
pool_size=10,
max_overflow=20,
pool_timeout=10,
pool_pre_ping=True,
pool_recycle=3600,
# Async-specific
echo=False, # True only in local debugging
future=True, # 2.0 API (default in SQLAlchemy 2.0)
connect_args={
"server_settings": {
"application_name": "bookstore-api", # appears in pg_stat_activity
"jit": "off", # optional, off for simple queries
},
# "statement_cache_size": 0, # ← We'll add it in capsule 06 with PgBouncer
},
)
SessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False, # critical in async, see below
autoflush=False, # explicit control of the flush
autocommit=False, # explicit for clarity
)
What each async-relevant parameter does
echo=False
When it's True, SQLAlchemy logs every SQL executed. Useful in local debugging, unviable in production — you multiply the log by 10x minimum and kill performance. If you need temporary SQL logging in production, use auto_explain (module 5) instead of echo.
future=True
Enables SQLAlchemy's 2.0 API. In 2.0+ it's the default, so it's not necessary explicitly. We leave it visible for clarity.
connect_args.server_settings
They pass to the PostgreSQL server at the start of each session. Useful ones:
application_name: appears inpg_stat_activityand in logs. Essential for distinguishing your app from others connected to the same DB. Put something descriptive.jit: if your app does only simple queries (most of FastAPI), turning JIT off ('off') can improve latency (JIT has compilation overhead you only amortize on large queries).statement_timeout: aborts queries that take longer than N ms. Useful as a safety net:statement_timeout: '5000'kills queries that exceed 5 seconds.
connect_args.statement_cache_size (capsule 06)
Specific to asyncpg. Default: 100 (caches up to 100 prepared statements per connection). When you use PgBouncer transaction mode, we'll set it to 0 to avoid the broken prepared statements bug.
expire_on_commit=False: why it's critical in async
This async_sessionmaker parameter is the most commonly misunderstood. It's mandatory in async.
What expire_on_commit=True does (default in sync): after a commit, all loaded objects are marked as "expired". The next access to any attribute triggers a SQL query to refresh.
Why that breaks in async:
# Antipattern with expire_on_commit=True
async with SessionLocal() as session:
book = await session.get(Book, 1)
book.title = "Updated"
await session.commit()
# ← At this point, book is "expired"
print(book.title) # ← Tries to lazy-load → MissingGreenlet error in async
After the commit, accessing book.title would require triggering a synchronous SQL query from async code. SQLAlchemy refuses and raises the MissingGreenlet you saw in module 4.
Solution: expire_on_commit=False.
# Correct pattern
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with SessionLocal() as session:
book = await session.get(Book, 1)
book.title = "Updated"
await session.commit()
print(book.title) # ← Works, the objects keep their state post-commit
Caveat: the objects may have "stale" data if another transaction modified the DB in parallel. For cases where you need fresh data, use an explicit await session.refresh(obj).
Practical rule: in async, always expire_on_commit=False. If you need a refresh, do it explicitly.
asyncpg's statement cache
This is the most technical detail of the capsule and the basis for understanding the PgBouncer gotcha (capsule 06).
What prepared statements are
When you run a query with parameters:
SELECT * FROM books WHERE author_id = $1
PostgreSQL can:
Option A — simple execution: parses, plans, and executes each time. Small but constant overhead.
Option B — prepared statement: parses and plans once, saves the plan associated with a name, and reuses the plan in subsequent executions. Faster on queries that repeat.
-- Prepare (happens once)
PREPARE stmt_42 AS SELECT * FROM books WHERE author_id = $1;
-- Execute (happens many times, reusing the plan)
EXECUTE stmt_42(15);
EXECUTE stmt_42(28);
What asyncpg does
asyncpg automatically prepares each distinct query and caches it per connection. The default cache has capacity for 100 statements. Each new execution of the same query uses the cached plan.
# When asyncpg sees this query for the first time:
await conn.fetch("SELECT * FROM books WHERE author_id = $1", 5)
# → PREPARE __asyncpg_stmt_xxxxx__ AS SELECT * FROM books WHERE author_id = $1
# → EXECUTE __asyncpg_stmt_xxxxx__(5)
# When it sees it the second time (same query, different param):
await conn.fetch("SELECT * FROM books WHERE author_id = $1", 12)
# → EXECUTE __asyncpg_stmt_xxxxx__(12) ← reuses the plan, without re-preparing
Why it matters
Benefit: repeated queries are ~5-15% faster (plan reused).
Problem with PgBouncer transaction mode: PgBouncer reassigns real connections to different clients per transaction. If your client prepared stmt_xxxxx on connection #5 and then PgBouncer hands it connection #7, stmt_xxxxx doesn't exist on connection #7. Error:
asyncpg.exceptions.InvalidSQLStatementNameError:
prepared statement "__asyncpg_stmt_xxxxx__" does not exist
Random, intermittent, hard to reproduce locally.
Fix (capsule 06):
engine = create_async_engine(
DATABASE_URL,
connect_args={"statement_cache_size": 0}, # disables the cache
)
Trade-off: you lose the ~5-15% optimization from plan reuse. You gain: your app doesn't break in production behind PgBouncer.
Important note: this fix is only necessary with PgBouncer transaction mode. Without PgBouncer (client directly to PostgreSQL) or with PgBouncer in session mode, leave statement_cache_size at its default (100).
The canonical FastAPI pattern: session-per-request with Depends
This is the pattern that avoids 90% of leaks in production. If you internalize it, most problems disappear.
# app/db.py
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession
# (engine and SessionLocal defined above)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""Dependency that opens and closes a session per request."""
async with SessionLocal() as session:
try:
yield session
finally:
await session.close() # explicit for clarity, the async with also does it
# app/routes/books.py
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_db
from app.models import Book
router = APIRouter()
@router.get("/books/{book_id}")
async def get_book(
book_id: int,
db: AsyncSession = Depends(get_db),
):
result = await db.scalar(select(Book).where(Book.id == book_id))
if not result:
raise HTTPException(404, "Book not found")
return result
What this pattern guarantees
- Each HTTP request has its own session. There's no shared state between requests.
- The session always closes when the request finishes, even if there's an exception.
- The connection returns to the pool automatically on closing the session.
- There's no way to forget to close. The
async withdoes it.
Antipatterns to avoid
Antipattern 1 — global session:
# ❌ NEVER do this in async
session = SessionLocal() # global, a single one
@app.get("/books")
async def get_books():
return await session.execute(...) # shares the session between requests
Why it fails: multiple concurrent requests share the same session, which causes:
- Concurrent execution errors (
Already attached to a connection). - Mixed transaction states (one request commits, another is left with half-finished data).
- Impossible to debug because the behavior depends on the exact temporal order.
Antipattern 2 — opening a session without a context manager:
# ❌ Don't do this
@app.get("/books")
async def get_books():
session = SessionLocal()
result = await session.execute(...)
return result
# the session never closes → leak
Why it fails: each request opens a connection that is never released. After N requests (where N = pool_size + max_overflow), your pool is saturated forever.
Antipattern 3 — multiple sessions per request without need:
# ❌ Unnecessary
@app.get("/books/{id}")
async def get_book(id: int, db: AsyncSession = Depends(get_db)):
book = await db.get(Book, id)
async with SessionLocal() as another_session: # ← redundant
author = await another_session.get(Author, book.author_id)
return {"book": book, "author": author}
Why it fails: it consumes two connections from the pool per request. If you do this in a popular endpoint, it saturates the pool at double the expected rate.
Solution: always use the session injected by Depends(get_db).
Lifespan handler: closing the engine when shutting down the app
FastAPI 0.110+ uses Starlette's lifespan handler for setup/teardown:
# app/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.db import engine
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup (before the yield)
print("App starting up")
yield
# Shutdown (after the yield)
print("App shutting down, disposing engine")
await engine.dispose()
app = FastAPI(lifespan=lifespan)
# Your routers
from app.routes import books
app.include_router(books.router)
Why await engine.dispose() matters
engine.dispose() closes all the pool's connections in an orderly way. Without this:
- In development: when you kill the server with Ctrl+C, the connections stay hung until PostgreSQL detects them as dead (it can take minutes).
- In production: a rolling deploy with Kubernetes kills pods. Without
dispose, the old pod's connections stay pointing until the timeout. You accumulate zombie connections on each deploy. - In testing: if you don't close the engine,
pytestcan hang at the end of the suite because the connections are open.
Rule: always close the engine in the lifespan shutdown.
Complete recommended configuration for production
# app/db.py
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
DATABASE_URL = "postgresql+asyncpg://bookstore:bookstore@db:5432/bookstore"
# Engine: a single instance for the whole app
engine = create_async_engine(
DATABASE_URL,
# Pool
pool_size=10,
max_overflow=20,
pool_timeout=10,
pool_pre_ping=True,
pool_recycle=3600,
# Async / asyncpg
echo=False,
connect_args={
"server_settings": {
"application_name": "bookstore-api",
"statement_timeout": "10000", # kills queries longer than 10s
},
# statement_cache_size is added when you introduce PgBouncer (capsule 06)
},
)
SessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with SessionLocal() as session:
try:
yield session
finally:
await session.close()
@asynccontextmanager
async def lifespan(app: FastAPI):
# startup
yield
# shutdown
await engine.dispose()
This snippet is ~50 lines and is the base for any production FastAPI API. Capsule 07 extends it with pool monitoring.
Why this matters in real work
1. asyncpg + AsyncEngine is the canonical stack. Official FastAPI templates (full-stack-fastapi-postgresql, fastapi-best-practices), Litestar starters, and the Supabase backend use exactly this setup. Knowing it is what differentiates a dev who "knows FastAPI" from one who "knows production FastAPI".
2. expire_on_commit=False is the #1 cause of MissingGreenlet. If you leave the default, you'll have cryptic errors after every commit. Knowing it from the start saves you hours of debugging.
3. The session-per-request pattern with Depends is a prerequisite for scaling. Without it, every "the API hangs" bug ends up being a leak. With it, debugging focuses on slow queries, not connection management.
4. asyncpg's statement cache is information that doesn't appear in blogs. Until you introduce PgBouncer and it breaks, nobody is going to explain it to you. Knowing it beforehand (and understanding why the fix is statement_cache_size=0) saves you the subtlest incident that can happen to you.
5. The lifespan handler is one of the first things they check in a senior code review. "Do you close the engine on shutdown?" If you don't, there's a comment on every PR.
Traps and common mistakes
Mistake 1 (conceptual): leaving expire_on_commit=True in async
Symptom: after await session.commit(), accessing any attribute of the object raises MissingGreenlet.
Why it happens: the sync default of expire_on_commit=True means that post-commit all objects are "expired" — the next access triggers a lazy load. In async that's the error you saw in module 4.
How to distinguish: the error comes after the commit, not before. If the pattern is await commit() → access → error, this is it.
How to fix it:
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
For cases where you need fresh data post-commit, use an explicit await session.refresh(obj).
Mistake 2 (operational): not calling await engine.dispose() on shutdown
Symptom: "Every time I restart the app, pg_stat_activity shows connections from the previous version for several minutes."
Why it happens: without dispose(), the connections stay open until the TCP keepalive detects them as dead (typically 2-5 minutes).
How to distinguish: after a server restart, pg_stat_activity shows application_name = bookstore-api with a state_change before the restart.
How to fix it: use a lifespan handler with await engine.dispose() on shutdown. If you use Gunicorn/uvicorn with worker recycling, make sure the lifespan fires in each worker (generally it does).
Mistake 3 (conceptual): sharing a session between requests
Symptom: intermittent errors like This Session is currently in 'committed' state, no further SQL can be emitted or strange results where one request sees another's data.
Why it happens: some design accidentally shares a global session. The concurrent requests overwrite each other's session state.
How to distinguish: the errors are intermittent and depend on the request order. Reproducing locally with a single request is hard; it appears under load.
How to fix it: always use Depends(get_db). One session per request, no exceptions. If you need long transactional operations that don't fit in a request, use background workers (Celery, ARQ) with their own session, not the HTTP request's.
Mistake 4 (operational): not setting application_name
Symptom: when investigating pg_stat_activity during an incident, you can't distinguish your API's connections from other apps connected to the same DB.
Why it happens: without application_name configured, pg_stat_activity shows generic values like psql, python, or empty.
How to distinguish: query SELECT application_name, count(*) FROM pg_stat_activity GROUP BY application_name. If you see many rows with an empty or generic application_name, this is it.
How to fix it: always set application_name in connect_args:
connect_args={
"server_settings": {"application_name": "bookstore-api"},
}
For advanced cases, you can include per-instance info: f"bookstore-api-{INSTANCE_ID}". Useful for distinguishing connections from different pods in Kubernetes.
Mistake 5 (conceptual): assuming asyncpg "has no cache" because it's async
Symptom: you enable PgBouncer transaction mode expecting "everything to work" because your app is async. After a few minutes, intermittent prepared statement does not exist errors begin.
Why it happens: asyncpg caches prepared statements automatically, independent of async. PgBouncer transaction mode reassigns connections, breaking the cache.
How to distinguish: the specific error asyncpg.exceptions.InvalidSQLStatementNameError: prepared statement "__asyncpg_stmt_xxxxx__" does not exist. It appears intermittently, not on every request.
How to fix it: capsule 06 covers this in detail. Spoiler: connect_args={"statement_cache_size": 0} when you use PgBouncer transaction mode.
Exercises
Exercise 1: complete setup from scratch
Create an app/db.py for a new FastAPI API following the canonical pattern. Include the engine, sessionmaker, get_db dependency, and lifespan handler.
See solution
# app/db.py
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
DATABASE_URL = "postgresql+asyncpg://bookstore:bookstore@localhost:5432/bookstore"
engine = create_async_engine(
DATABASE_URL,
pool_size=10,
max_overflow=20,
pool_timeout=10,
pool_pre_ping=True,
pool_recycle=3600,
echo=False,
connect_args={
"server_settings": {
"application_name": "bookstore-api",
"statement_timeout": "10000",
},
},
)
SessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with SessionLocal() as session:
try:
yield session
finally:
await session.close()
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
await engine.dispose()
# app/main.py
from fastapi import FastAPI
from app.db import lifespan
from app.routes import books
app = FastAPI(lifespan=lifespan)
app.include_router(books.router)
# app/routes/books.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_db
from app.models import Book
router = APIRouter(prefix="/books", tags=["books"])
@router.get("/{book_id}")
async def get_book(
book_id: int,
db: AsyncSession = Depends(get_db),
):
book = await db.scalar(select(Book).where(Book.id == book_id))
if not book:
raise HTTPException(404, "Book not found")
return book
Verify the setup:
uvicorn app.main:app --reload
Test in the browser http://localhost:8000/books/1. If it responds, the setup works.
Verify the connections:
SELECT application_name, count(*)
FROM pg_stat_activity
WHERE application_name = 'bookstore-api'
GROUP BY application_name;
It should show the active connections with your app name.
Exercise 2: reproduce and fix MissingGreenlet
Write an endpoint that reproduces MissingGreenlet by using expire_on_commit=True. Then fix it.
See solution
# app/routes/buggy.py
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
DATABASE_URL = "postgresql+asyncpg://bookstore:bookstore@localhost:5432/bookstore"
# Engine and session with expire_on_commit=True (default antipattern)
engine_buggy = create_async_engine(DATABASE_URL)
SessionBuggy = async_sessionmaker(engine_buggy, expire_on_commit=True)
# Engine and session with expire_on_commit=False (correct)
engine_ok = create_async_engine(DATABASE_URL)
SessionOk = async_sessionmaker(engine_ok, expire_on_commit=False)
router = APIRouter()
@router.post("/books/buggy")
async def update_buggy():
async with SessionBuggy() as session:
# Simulate an update
await session.execute(text("UPDATE books SET title = title WHERE id = 1"))
await session.commit()
# Load after the commit
result = await session.scalar(select(Book).where(Book.id == 1))
# This will break on the next access to an attribute:
return {"title": result.title} # ← MissingGreenlet here
@router.post("/books/correct")
async def update_correct():
async with SessionOk() as session:
await session.execute(text("UPDATE books SET title = title WHERE id = 1"))
await session.commit()
result = await session.scalar(select(Book).where(Book.id == 1))
return {"title": result.title} # Works
Test:
curl -X POST http://localhost:8000/books/buggy
# 500 Internal Server Error - MissingGreenlet in the logs
curl -X POST http://localhost:8000/books/correct
# {"title": "Some Book Title"}
Lesson: in async, expire_on_commit=False is the rule, not the exception.
Exercise 3: identify a leak from an unclosed session
You have this endpoint and you notice that after a few requests, the pool saturates. Identify the bug and fix it.
@app.get("/books/{book_id}")
async def get_book(book_id: int):
session = SessionLocal()
book = await session.scalar(select(Book).where(Book.id == book_id))
return book
See solution
Diagnosis:
SessionLocal() opens a session (and by extension a connection from the pool). return book ends the function but never closes the session. The connection stays hung in the pool, occupied.
After pool_size + max_overflow requests to the endpoint, the pool is saturated and new requests fail with TimeoutError.
Confirmation:
for i in {1..30}; do curl -s http://localhost:8000/books/1 > /dev/null; done
docker exec -it bookstore-pg psql -U bookstore -d bookstore -c "
SELECT count(*), state FROM pg_stat_activity
WHERE application_name = 'bookstore-api' GROUP BY state;
"
Output: many connections in idle or idle in transaction, not freeing up.
Correct fix (recommended):
@app.get("/books/{book_id}")
async def get_book(book_id: int, db: AsyncSession = Depends(get_db)):
book = await db.scalar(select(Book).where(Book.id == book_id))
return book
get_db handles the lifecycle with async with, guaranteeing closure.
Alternative fix (if you don't want to use Depends for some reason):
@app.get("/books/{book_id}")
async def get_book(book_id: int):
async with SessionLocal() as session:
book = await session.scalar(select(Book).where(Book.id == book_id))
return book
async with guarantees closure even if there's an exception.
Why Depends is preferable:
- It centralizes the creation/closure in a single place (
get_db). - It lets you add common logic (logging, metrics) without touching each endpoint.
- It's easier to mock in tests.
Exercise 4: configure application_name and verify in pg_stat_activity
Configure your engine so it appears with application_name = "my-bookstore-prod" in PostgreSQL. Verify with pg_stat_activity.
See solution
# app/db.py
import os
INSTANCE_ID = os.environ.get("INSTANCE_ID", "local")
engine = create_async_engine(
DATABASE_URL,
connect_args={
"server_settings": {
"application_name": f"my-bookstore-prod-{INSTANCE_ID}",
},
},
)
Start the app:
INSTANCE_ID=worker-1 uvicorn app.main:app
Make a request to open a connection:
curl http://localhost:8000/books/1
Verify:
docker exec -it bookstore-pg psql -U bookstore -d bookstore -c "
SELECT application_name, count(*), state
FROM pg_stat_activity
WHERE application_name LIKE 'my-bookstore-prod%'
GROUP BY application_name, state;
"
Expected output:
application_name | count | state
-------------------------------+-------+--------
my-bookstore-prod-worker-1 | 10 | idle
my-bookstore-prod-worker-1 | 1 | active
In production with multiple instances (Kubernetes, ECS, etc.):
# In each pod
INSTANCE_ID=$HOSTNAME uvicorn app.main:app
# Then, the query shows:
SELECT application_name, count(*) FROM pg_stat_activity
WHERE application_name LIKE 'my-bookstore-prod%'
GROUP BY application_name;
application_name | count
-------------------------------------+-------
my-bookstore-prod-pod-abc123 | 10
my-bookstore-prod-pod-def456 | 10
my-bookstore-prod-pod-ghi789 | 10
Useful when you need to kill connections from a specific pod (rolling deploy, debugging) without touching the others.
Exercise 5: test the effect of asyncpg's statement cache
Connect directly with asyncpg (without SQLAlchemy) and verify that repeated queries are cached. Then test with statement_cache_size=0 and note the difference.
See solution
# bench_stmt_cache.py
import asyncio
import time
import asyncpg
DSN = "postgresql://bookstore:bookstore@localhost:5432/bookstore"
async def benchmark(stmt_cache_size: int):
conn = await asyncpg.connect(DSN, statement_cache_size=stmt_cache_size)
# Warm up: run the query once
await conn.fetch("SELECT * FROM books WHERE id = $1", 1)
# Measure 1000 executions
start = time.perf_counter()
for i in range(1000):
await conn.fetch("SELECT * FROM books WHERE id = $1", i % 100 + 1)
elapsed = time.perf_counter() - start
await conn.close()
return elapsed
async def main():
cached = await benchmark(stmt_cache_size=100)
no_cache = await benchmark(stmt_cache_size=0)
print(f"With cache (default): {cached*1000:.1f}ms total ({cached:.2f}ms per query)")
print(f"Without cache: {no_cache*1000:.1f}ms total ({no_cache:.2f}ms per query)")
print(f"Difference: {((no_cache - cached) / cached) * 100:.1f}% slower without cache")
asyncio.run(main())
Typical output (on localhost):
With cache (default): 320.5ms total (0.32ms per query)
Without cache: 385.2ms total (0.39ms per query)
Difference: 20.2% slower without cache
Analysis:
- With cache: ~0.32ms per query.
- Without cache: ~0.39ms per query.
- Overhead of not caching: ~70 microseconds per query.
For simple queries like SELECT WHERE id = $1, the difference is modest (~20%). For complex queries with heavy execution plans, the difference can be larger (up to 2x).
Operational conclusion: disabling the cache (necessary with PgBouncer transaction mode) costs performance but is manageable. If latency is critical and you need the cache, consider PgBouncer session mode (more real connections but it allows prepared statements).
Summary and next step
In this capsule you:
- Confirmed asyncpg as the canonical driver for FastAPI with SQLAlchemy 2.0.
- Learned the complete setup of
create_async_engine,async_sessionmaker, and theget_dbdependency. - Internalized
expire_on_commit=Falseas mandatory in async. - Learned about asyncpg's statement cache and why it matters (a hook to capsule 06).
- Adopted the session-per-request pattern with
Depends(get_db). - Configured a lifespan handler with
await engine.dispose()for a clean shutdown.
Before moving on, you should be able to:
- Create
app/db.pyfrom scratch following the canonical pattern, without copying it. - Diagnose
MissingGreenletand know that the fix isexpire_on_commit=False. - Identify connection leaks from unclosed sessions in code reviews.
- Set
application_nameso your connections are identifiable inpg_stat_activity.
Next capsule — PgBouncer fundamentals. So far all the pooling lives in the client (SQLAlchemy + asyncpg). In the next capsule we introduce PgBouncer: the external pool that sits between your app and PostgreSQL. You're going to see why it's necessary at a certain scale (remember the "4 instances × pool_size > max_connections" calculation from capsule 02), how it works architecturally, and the three pooling modes (session/transaction/statement) with the complete decision matrix among them. That capsule and 06 are the most technically loaded of the module.
Resources
- SQLAlchemy 2.0 —
AsyncEngine— the complete official async reference. - asyncpg documentation — the official driver.
- asyncpg — Statement Cache — the specific section on the prepared statements cache.
- FastAPI — SQLAlchemy 2.0 + asyncpg tutorial — canonical patterns.
- Mike Bayer — "What's New in SQLAlchemy 2.0" — relevant changes in async.
- full-stack-fastapi-postgresql template — a reference setup with asyncpg.
Module 6 — Database Performance & Query Tuning Guide