Let’s Start With a Real Problem
Your API is live. It’s handling a steady trickle of requests just fine. Then one day, marketing runs a campaign, traffic spikes 10x, and suddenly every request feels sluggish — even simple ones. You check your server’s CPU usage… and it’s barely being used at all. Your server isn’t busy. It’s waiting — for the database to respond, for a slow third-party API call, for a file to finish uploading. And while it waits, it’s not able to do anything else.
This is one of the most misunderstood performance problems in backend development, and it’s exactly what async was built to solve. In this chapter, you’ll understand precisely when async def actually helps (and when it doesn’t), see the difference with your own eyes, and convert a real FastAPI + PostgreSQL project to be fully asynchronous.
What You’ll Learn
- The real difference between synchronous and asynchronous code — not just “async is faster”
- Why async helps with I/O-bound work but does nothing for CPU-bound work
- How FastAPI actually handles regular
defroutes behind the scenes (a detail almost every beginner misunderstands) - How to see the difference between sync and async with a real, measurable test
- How to convert a SQLAlchemy + PostgreSQL project to fully async, using
asyncpgandAsyncSession - Theory + hands-on assignment questions to lock in what you’ve learned
Theory: What Does “Async” Actually Mean?
Most of the code you’ve written so far runs synchronously — one line finishes completely before the next one starts. If a line of code has to wait (for a database, a network call, a file), your entire program just… waits too, doing nothing else in the meantime.
Asynchronous code allows your program to say: “I’m waiting on this database call — while I wait, go handle something else, and come back to me when it’s ready.” This is managed by something called an event loop, which juggles many waiting tasks at once, all on a single thread.
Here’s the detail that trips up most beginners: async does not make individual operations faster. A database query that takes 200ms still takes 200ms, whether it’s sync or async. What async improves is how many of those operations your server can juggle at the same time while waiting.
The Critical Distinction: I/O-Bound vs. CPU-Bound
| Type | Examples | Does Async Help? |
|---|---|---|
| I/O-bound | Database queries, HTTP calls to other APIs, reading/writing files, waiting on user input | Yes — the server is idle while waiting, and async lets it do other work during that idle time |
| CPU-bound | Image processing, complex calculations, encryption, large loops | No — the CPU is genuinely busy the whole time; there’s no “waiting” for async to take advantage of |
Almost everything a typical REST API does — talking to a database, calling other services — is I/O-bound. That’s exactly why async matters so much for backend APIs specifically.
A Detail FastAPI Beginners Often Miss
You might assume that a regular def route in FastAPI blocks the entire server while it runs. That’s not quite true — FastAPI automatically runs synchronous def route functions in a separate thread pool, so one slow sync route doesn’t freeze the whole application for everyone. However, that thread pool has a limited size (40 threads by default). Under heavy concurrent load, sync routes can still queue up waiting for a free thread, while a well-written async route can handle a much larger number of concurrent waiting requests using a single thread. That’s the difference you’re about to measure yourself.
Program 1: Seeing the Difference — Sync vs. Async Under Load
Program Objective
Build a small, standalone FastAPI project with two nearly identical endpoints — one synchronous, one asynchronous — that both “wait” for 2 seconds (simulating something like a slow API call). Then fire many concurrent requests at each and measure the real difference.
Step-by-Step Implementation
Step 1: Create the project
uv init sync-vs-async-demo
cd sync-vs-async-demo
uv add fastapi --extra standard
uv add httpx
💡
httpxisn’t used by the app itself here — we’re adding it to write a small load-testing script.
Step 2: Create the project structure
sync-vs-async-demo/
├── app/
│ ├── __init__.py
│ └── main.py
├── load_test.py
├── pyproject.toml
└── uv.lock
mkdir -p app
touch app/__init__.py
Step 3: Create each file with the code below. Step 4: Run and test.
Complete Working Source Code
app/main.py
import asyncio
import time
from fastapi import FastAPI
app = FastAPI(title="Sync vs Async Demo", version="1.0.0")
@app.get("/sync-wait")
def sync_wait():
time.sleep(2) # blocks the thread it's running on
return {"message": "Sync route finished waiting."}
@app.get("/async-wait")
async def async_wait():
await asyncio.sleep(2) # frees the event loop while waiting
return {"message": "Async route finished waiting."}
load_test.py
import asyncio
import sys
import time
import httpx
async def fire_requests(url: str, count: int) -> None:
async with httpx.AsyncClient(timeout=60.0) as client:
start = time.perf_counter()
tasks = [client.get(url) for _ in range(count)]
await asyncio.gather(*tasks)
elapsed = time.perf_counter() - start
print(f"{count} requests to {url} finished in {elapsed:.2f} seconds")
if __name__ == "__main__":
endpoint = sys.argv[1] if len(sys.argv) > 1 else "sync-wait"
request_count = int(sys.argv[2]) if len(sys.argv) > 2 else 50
target_url = f"http://127.0.0.1:8000/{endpoint}"
asyncio.run(fire_requests(target_url, request_count))
Detailed Code Explanation
sync_waitusestime.sleep(2)— a genuinely blocking call. As explained above, FastAPI runs this in its internal thread pool, so a handful of concurrent requests can still be handled reasonably well. But each request occupies one full thread for the entire 2 seconds, and the thread pool has a fixed size.async_waitusesawait asyncio.sleep(2)— a non-blocking wait. While this route is “sleeping,” the single event loop thread is completely free to start processing other incoming requests. This is the fundamental mechanical difference between the two.load_test.pyis a small standalone script (not part of the FastAPI app) that fires many requests at a given URL concurrently, usingasyncio.gather(...)to launch them all at once rather than one after another, and measures the total wall-clock time for all of them to complete.- We deliberately default to
50concurrent requests — more than FastAPI’s default thread pool size (40) — specifically so the difference between sync and async becomes clearly visible rather than hidden by the thread pool comfortably absorbing a small number of requests.
Execution & Testing
Terminal 1 — start the FastAPI app:
uv run fastapi dev app/main.py
Terminal 2 — run the load test against each endpoint:
uv run load_test.py sync-wait 50
uv run load_test.py async-wait 50
What to expect:
async-waitshould finish all 50 requests in roughly ~2 seconds total — since the event loop can juggle all of them concurrently while each one “sleeps.”sync-waitwill likely take noticeably longer — since 50 requests exceed the default 40-thread pool size, some requests have to wait in a queue for a thread to become free before their own 2-second wait can even begin.
Try lowering the count to 20 (below the thread pool size) and re-running both — you’ll notice the gap mostly disappears, which reinforces the real lesson: async isn’t “faster” in isolation — it scales dramatically better under concurrent I/O-bound load.
Program 2: Converting a Full CRUD App to Async (PostgreSQL + asyncpg)
Program Objective
Rebuild the Product CRUD API from Chapter 10 using fully asynchronous SQLAlchemy — an async engine, AsyncSession, and async def routes and service functions throughout — connected to a real PostgreSQL database.
Step-by-Step Implementation
Step 1: Start a PostgreSQL container
docker run --name fastapi-postgres-async -e POSTGRES_USER=fastapi_user -e POSTGRES_PASSWORD=fastapi_pass -e POSTGRES_DB=async_products_db -p 5434:5432 -d postgres:16
Step 2: Create the project
uv init async-crud-demo
cd async-crud-demo
uv add fastapi --extra standard
uv add pydantic-settings
uv add sqlalchemy
uv add asyncpg
💡 Notice we install
asyncpgthis time, notpsycopg[binary].asyncpgis a PostgreSQL driver built specifically for async Python, and it’s what SQLAlchemy’s async engine expects for PostgreSQL connections.
Step 3: Create the project structure
mkdir -p app/core app/db app/models app/schemas app/services app/routers
touch app/__init__.py app/core/__init__.py app/db/__init__.py app/models/__init__.py app/schemas/__init__.py app/services/__init__.py app/routers/__init__.py
async-crud-demo/
├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── core/
│ │ ├── __init__.py
│ │ ├── config.py
│ │ └── exceptions.py
│ ├── db/
│ │ ├── __init__.py
│ │ └── database.py
│ ├── models/
│ │ ├── __init__.py
│ │ └── product.py
│ ├── schemas/
│ │ ├── __init__.py
│ │ └── product.py
│ ├── services/
│ │ ├── __init__.py
│ │ └── product_service.py
│ └── routers/
│ ├── __init__.py
│ └── products.py
├── pyproject.toml
└── uv.lock
Step 4: Create each file with the code below. Step 5: Run and test.
Complete Working Source Code
app/core/config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "Async CRUD Demo"
app_version: str = "1.0.0"
database_url: str = "postgresql+asyncpg://fastapi_user:fastapi_pass@localhost:5434/async_products_db"
settings = Settings()
app/core/exceptions.py
class ProductNotFoundError(Exception):
"""Raised when a requested product does not exist."""
def __init__(self, product_id: int):
self.product_id = product_id
super().__init__(f"Product with id {product_id} was not found.")
app/db/database.py
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = async_sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db():
async with SessionLocal() as session:
yield session
app/models/product.py
from sqlalchemy import Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.database import Base
class Product(Base):
__tablename__ = "products"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(100), index=True)
description: Mapped[str] = mapped_column(String(1000))
price: Mapped[float] = mapped_column(Numeric(10, 2))
in_stock: Mapped[bool] = mapped_column(default=True)
app/schemas/product.py
from typing import Annotated
from pydantic import BaseModel, ConfigDict, Field
class ProductCreate(BaseModel):
name: Annotated[str, Field(min_length=2, max_length=100)]
description: Annotated[str, Field(min_length=1, max_length=1000)]
price: Annotated[float, Field(gt=0)]
in_stock: Annotated[bool, Field(default=True)]
class ProductPatch(BaseModel):
name: Annotated[str | None, Field(default=None, min_length=2, max_length=100)]
description: Annotated[str | None, Field(default=None, min_length=1, max_length=1000)]
price: Annotated[float | None, Field(default=None, gt=0)]
in_stock: Annotated[bool | None, Field(default=None)]
class ProductOut(BaseModel):
id: int
name: str
description: str
price: float
in_stock: bool
model_config = ConfigDict(from_attributes=True)
app/services/product_service.py
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ProductNotFoundError
from app.models.product import Product
from app.schemas.product import ProductCreate, ProductPatch
async def create_product(db: AsyncSession, product_in: ProductCreate) -> Product:
new_product = Product(**product_in.model_dump())
db.add(new_product)
await db.commit()
await db.refresh(new_product)
return new_product
async def list_products(db: AsyncSession) -> list[Product]:
result = await db.execute(select(Product))
return list(result.scalars().all())
async def get_product_by_id(db: AsyncSession, product_id: int) -> Product:
product = await db.get(Product, product_id)
if product is None:
raise ProductNotFoundError(product_id)
return product
async def patch_product(db: AsyncSession, product_id: int, data: ProductPatch) -> Product:
product = await get_product_by_id(db, product_id)
update_data = data.model_dump(exclude_unset=True)
for field_name, value in update_data.items():
setattr(product, field_name, value)
await db.commit()
await db.refresh(product)
return product
async def delete_product(db: AsyncSession, product_id: int) -> None:
product = await get_product_by_id(db, product_id)
await db.delete(product)
await db.commit()
app/routers/products.py
from fastapi import APIRouter, Depends, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.database import get_db
from app.schemas.product import ProductCreate, ProductOut, ProductPatch
from app.services.product_service import (
create_product,
delete_product,
get_product_by_id,
list_products,
patch_product,
)
router = APIRouter(prefix="/products", tags=["Products"])
@router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
async def create_new_product(product_in: ProductCreate, db: AsyncSession = Depends(get_db)):
return await create_product(db, product_in)
@router.get("/", response_model=list[ProductOut])
async def get_all_products(db: AsyncSession = Depends(get_db)):
return await list_products(db)
@router.get("/{product_id}", response_model=ProductOut)
async def get_product(product_id: int, db: AsyncSession = Depends(get_db)):
return await get_product_by_id(db, product_id)
@router.patch("/{product_id}", response_model=ProductOut)
async def update_product(product_id: int, data: ProductPatch, db: AsyncSession = Depends(get_db)):
return await patch_product(db, product_id, data)
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_product(product_id: int, db: AsyncSession = Depends(get_db)):
await delete_product(db, product_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
app/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse
from app.core.config import settings
from app.core.exceptions import ProductNotFoundError
from app.db.database import Base, engine
from app.models import product # noqa: F401
from app.routers import products
@asynccontextmanager
async def lifespan(app: FastAPI):
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
app = FastAPI(title=settings.app_name, version=settings.app_version, lifespan=lifespan)
app.include_router(products.router)
@app.exception_handler(ProductNotFoundError)
def product_not_found_handler(request: Request, exc: ProductNotFoundError):
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={"error_code": "PRODUCT_NOT_FOUND", "detail": str(exc)},
)
@app.get("/", tags=["Root"])
def read_root():
return {"message": f"Welcome to {settings.app_name}"}
Detailed Code Explanation
database.pynow usescreate_async_engineinstead ofcreate_engine, andasync_sessionmaker(... class_=AsyncSession ...)instead of the plainsessionmaker.expire_on_commit=Falseis a common async setting — it prevents SQLAlchemy from needing an extra database round-trip to refresh object attributes right after a commit, which matters more in async code where every round-trip is anawait.get_db()is nowasync defand usesasync with SessionLocal() as session: yield session— the async equivalent of the try/finally pattern from earlier chapters, letting the session context manager handle proper cleanup automatically.- The
Productmodel itself didn’t need to change at all — table definitions withMappedandmapped_columnare identical whether you’re using sync or async SQLAlchemy. Only the engine and session layer changes. - In
product_service.py, every function is nowasync def, and every database operation isawait-ed:await db.commit()andawait db.refresh(...)— committing and refreshing are I/O operations, so they must be awaited.list_productscan no longer use the old.query(Product).all()shortcut — the async engine requires the more explicitselect()statement style:await db.execute(select(Product)), then.scalars().all()to unwrap the actualProductobjects from the result.await db.get(Product, product_id)— even this simple lookup-by-primary-key needs anawait, since it’s still a real database round-trip.
routers/products.py— every route function isasync def, and every call into the service layer isawait-ed.Depends(get_db)works exactly the same way as before; FastAPI recognizes thatget_dbis an async generator and handles it correctly.main.pyintroduces a lifespan context manager — the modern, recommended way to run startup/shutdown logic in FastAPI. We use it here because creating tables (Base.metadata.create_all) is normally a synchronous SQLAlchemy operation;conn.run_sync(...)is the official bridge that lets us safely call that sync function from within an async context, once, when the app starts up.
⚠️ Migration checklist: When converting any project to async SQLAlchemy, look for exactly these four changes: (1)
create_engine→create_async_engine, (2)Session→AsyncSession, (3) every database call gets anawait, and (4).query(Model)becomesawait db.execute(select(Model)). Everything else — models, schemas, routing structure — stays the same.
Execution & Testing
uv run fastapi dev app/main.py
Open http://127.0.0.1:8000/docs and run through the full CRUD flow exactly as you did in Chapter 10:
- Create:
POST /products/ - Read all:
GET /products/ - Read one:
GET /products/{id} - Update:
PATCH /products/{id} - Delete:
DELETE /products/{id}
Everything should behave identically from the outside — that’s the point. The API’s behavior hasn’t changed at all; only its ability to handle many concurrent requests efficiently has improved underneath.
Tips & Instructions for Developers
- ✅ Reach for
async defwhen your route does I/O — database calls, external API calls, file access. That’s where the benefit actually lives. - ✅ Don’t mix a blocking call (like
time.sleep()or a synchronous library call) inside anasync defroute — that blocks the entire event loop for every user, which is worse than a normal sync route being handled by the thread pool. - ✅ If you must call a blocking, CPU-heavy function from inside an async route, look into
run_in_threadpoolorasyncio.to_threadto offload it safely — a topic worth exploring further as your app grows. - ✅ Keep your whole database layer consistently either sync or async — mixing
SessionandAsyncSessionpatterns in the same project gets confusing fast. - ✅ Remember:
async defalone is not a performance guarantee. If everything inside it is CPU-bound work with no real “waiting,” it won’t help. - ❌ Don’t assume async makes a single request faster — the benefit shows up under concurrent load, not on an isolated request.
- ❌ Don’t forget
awaiton any async call — a missingawaitreturns an unfinished coroutine object instead of your actual data, a very common and confusing async bug.
Official FastAPI Documentation References
- Concurrency and async / await
- Async SQL (Relational) Databases
- SQLAlchemy Asynchronous I/O (asyncio)
- Lifespan Events
- asyncpg Documentation
Theory Questions
- Why does async improve performance for I/O-bound work but not for CPU-bound work?
- How does FastAPI actually handle a regular (non-async)
defroute function internally? - Why did
list_productsneed to change from.query(Product).all()toawait db.execute(select(Product))when moving to async SQLAlchemy? - What problem does
conn.run_sync(Base.metadata.create_all)solve inside an asynclifespanfunction? - Why is calling a blocking function (like
time.sleep()) inside anasync defroute considered worse than using it inside a regulardefroute?
Practical Assignment
- Measure the crossover point: Using Program 1’s
load_test.py, experiment with different request counts (10,30,40,60,100) against both endpoints, and note down roughly where the sync endpoint’s total time starts growing noticeably worse than the async endpoint’s. - Convert a relationship: Starting from Program 2’s project, add a
Categorymodel with a one-to-many relationship toProduct(as in Chapter 8’s assignment), but written using async patterns from the start. - Find the mistake: Deliberately remove one
awaitkeyword somewhere inproduct_service.py(for example, fromdb.commit()), restart the app, and try to use that endpoint. Write down exactly what error or unexpected behavior you observe — this builds the instinct to instantly recognize a missingawaitin real projects.
Summary
In this chapter, you learned:
- The real difference between synchronous and asynchronous code, and why it only matters for I/O-bound work
- How FastAPI handles regular
defroutes using an internal thread pool - How to measure the real-world difference between sync and async under concurrent load
- How to convert a full SQLAlchemy + PostgreSQL CRUD project to fully async, using
asyncpg,AsyncSession, andselect()statements - The four-point checklist for migrating any project from sync to async SQLAlchemy
