Chapter 11: Sync vs Async — Converting Your App to Asynchronous in FastAPI

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 def routes 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 asyncpg and AsyncSession
  • 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

TypeExamplesDoes Async Help?
I/O-boundDatabase queries, HTTP calls to other APIs, reading/writing files, waiting on user inputYes — the server is idle while waiting, and async lets it do other work during that idle time
CPU-boundImage processing, complex calculations, encryption, large loopsNo — 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

💡 httpx isn’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_wait uses time.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_wait uses await 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.py is a small standalone script (not part of the FastAPI app) that fires many requests at a given URL concurrently, using asyncio.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 50 concurrent 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-wait should finish all 50 requests in roughly ~2 seconds total — since the event loop can juggle all of them concurrently while each one “sleeps.”
  • sync-wait will 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 asyncpg this time, not psycopg[binary]. asyncpg is 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.py now uses create_async_engine instead of create_engine, and async_sessionmaker(... class_=AsyncSession ...) instead of the plain sessionmaker. expire_on_commit=False is 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 an await.
  • get_db() is now async def and uses async 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 Product model itself didn’t need to change at all — table definitions with Mapped and mapped_column are identical whether you’re using sync or async SQLAlchemy. Only the engine and session layer changes.
  • In product_service.py, every function is now async def, and every database operation is await-ed:
    • await db.commit() and await db.refresh(...) — committing and refreshing are I/O operations, so they must be awaited.
    • list_products can no longer use the old .query(Product).all() shortcut — the async engine requires the more explicit select() statement style: await db.execute(select(Product)), then .scalars().all() to unwrap the actual Product objects from the result.
    • await db.get(Product, product_id) — even this simple lookup-by-primary-key needs an await, since it’s still a real database round-trip.
  • routers/products.py — every route function is async def, and every call into the service layer is await-ed. Depends(get_db) works exactly the same way as before; FastAPI recognizes that get_db is an async generator and handles it correctly.
  • main.py introduces 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 an await, and (4) .query(Model) becomes await 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:

  1. Create: POST /products/
  2. Read all: GET /products/
  3. Read one: GET /products/{id}
  4. Update: PATCH /products/{id}
  5. 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 def when 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 an async def route — 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_threadpool or asyncio.to_thread to offload it safely — a topic worth exploring further as your app grows.
  • ✅ Keep your whole database layer consistently either sync or async — mixing Session and AsyncSession patterns in the same project gets confusing fast.
  • ✅ Remember: async def alone 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 await on any async call — a missing await returns an unfinished coroutine object instead of your actual data, a very common and confusing async bug.

Official FastAPI Documentation References


Theory Questions

  1. Why does async improve performance for I/O-bound work but not for CPU-bound work?
  2. How does FastAPI actually handle a regular (non-async) def route function internally?
  3. Why did list_products need to change from .query(Product).all() to await db.execute(select(Product)) when moving to async SQLAlchemy?
  4. What problem does conn.run_sync(Base.metadata.create_all) solve inside an async lifespan function?
  5. Why is calling a blocking function (like time.sleep()) inside an async def route considered worse than using it inside a regular def route?

Practical Assignment

  1. 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.
  2. Convert a relationship: Starting from Program 2’s project, add a Category model with a one-to-many relationship to Product (as in Chapter 8’s assignment), but written using async patterns from the start.
  3. Find the mistake: Deliberately remove one await keyword somewhere in product_service.py (for example, from db.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 missing await in 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 def routes 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, and select() statements
  • The four-point checklist for migrating any project from sync to async SQLAlchemy
Scroll to Top