FastAPI Async Database Access
A database query spends most of its time waiting for the database server to answer. A synchronous route freezes its worker during that wait. An async route hands control back to the event loop, which serves other requests while the query runs. Think of a waiter who places an order in the kitchen and keeps serving other tables instead of standing at the pass until the dish is ready.
The Waiting Time Picture
Sync route: [send query][........ waiting ........][use result]
worker blocked
Async route: [send query] ──▶ event loop serves other requests ──▶ [use result]
worker stays busyBusy APIs gain the most, because many requests overlap during the waits.
Installing the Pieces
SQLAlchemy needs an async driver for your database.
pip install sqlalchemy aiosqlite # SQLite
pip install sqlalchemy asyncpg # PostgreSQLCreating the Async Engine and Session Factory
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
DATABASE_URL = "sqlite+aiosqlite:///./app.db"
# PostgreSQL: "postgresql+asyncpg://user:password@localhost/dbname"
engine = create_async_engine(DATABASE_URL)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)The driver name appears in the URL after the plus sign. The setting expire_on_commit=False keeps objects readable after a commit, which suits async code.
The Session Dependency
from fastapi import Depends
async def get_db():
async with SessionLocal() as session:
yield sessionEvery request receives its own session. The async with block closes it when the request ends.
Creating Tables at Startup
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
app = FastAPI(lifespan=lifespan)The run_sync call lets the table-creation helper run inside an async connection. Production projects replace this step with Alembic migrations.
Reading Data
from sqlalchemy import select
@app.get("/items")
async def list_items(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Item))
return result.scalars().all()
@app.get("/items/{item_id}")
async def read_item(item_id: int, db: AsyncSession = Depends(get_db)):
item = await db.get(Item, item_id)
return itemEvery database call carries the await keyword. Missing it returns a coroutine object instead of data.
Writing Data
@app.post("/items", status_code=201)
async def create_item(data: ItemCreate, db: AsyncSession = Depends(get_db)):
item = Item(**data.model_dump())
db.add(item)
await db.commit()
await db.refresh(item)
return item
db.add(item) ──▶ stages the new row in memory (no await needed)
await db.commit() ──▶ writes the row to the database
await db.refresh() ──▶ reloads generated values such as the new idLoading Related Data
Async sessions refuse lazy loading. Touching author.books without preparation raises a MissingGreenlet error. Request the related rows inside the query.
from sqlalchemy.orm import selectinload
stmt = select(Author).options(selectinload(Author.books))
result = await db.execute(stmt)
authors = result.scalars().all()Choosing Sync or Async
- Pick async when the app handles many simultaneous requests or calls other async services.
- Pick sync for small projects and simple scripts; the code stays shorter.
- Avoid mixing styles. A sync database call inside an
async defroute blocks the entire event loop.
Common Mistakes
Using a Sync Driver
The URL sqlite:/// belongs to the sync engine. Async engines need sqlite+aiosqlite:///.
Forgetting await
The route returns a strange object or a warning about a coroutine that never ran. Add await before every session call that talks to the database.
Sharing One Session
A global session breaks when two requests overlap. Create one session per request through the dependency.
A Small Timing Experiment
A quick experiment makes the benefit visible. Imagine a route that waits one second for the database. Ten visitors arrive at the same moment.
Sync worker, one process: 10 requests x 1 second = about 10 seconds for the last visitor
Async worker, one process: 10 requests overlap = about 1 second for every visitorThe async worker starts all ten queries and collects the answers as they arrive. The sync worker finishes one query before it touches the next. Real databases answer faster than one second, yet the same pattern holds at larger scale: a thousand overlapping requests make the gap far bigger.
Measure before you convert an existing project. Apps with light traffic gain little from async access, and the extra keywords add reading effort for new team members.
Key Points
- Async access frees the worker while the database answers.
- Use
create_async_engineandAsyncSessionwith an async driver. - Add
awaitbeforeexecute,commit,refresh, andget. - Load related rows with
selectinloadbecause lazy loading fails. - Give each request its own session through a dependency.
