Get AI summaries of any video or article — Sign up free
Python FastAPI Tutorial (Part 7): Sync vs Async - Converting Your App to Asynchronous thumbnail

Python FastAPI Tutorial (Part 7): Sync vs Async - Converting Your App to Asynchronous

Corey Schafer·
5 min read

Based on Corey Schafer's video on YouTube. If you like this content, support the original creators by watching, liking and subscribing to their content.

TL;DR

Async improves performance mainly for I/O-bound workloads under concurrent load, not for CPU-bound computation.

Briefing

The core takeaway is that FastAPI can run routes either synchronously or asynchronously, but the performance payoff from async only materializes when requests spend time waiting on external I/O (like databases or network calls). For CPU-heavy work, async won’t help and can even add overhead. The practical result in this tutorial is a full conversion of a CRUD-heavy FastAPI app from sync SQLAlchemy sessions to async SQLAlchemy, while keeping behavior the same—especially around relationship loading.

Async is framed as a way to avoid idle time. Instead of handling one task end-to-end (sync), asynchronous code can juggle multiple in-flight requests while waiting on external systems (I/O-bound tasks). The tutorial emphasizes that async doesn’t speed up computation; it helps when the program would otherwise block waiting for database responses, external API calls, or file reads. That distinction matters because it determines whether switching to async is worth the added complexity.

FastAPI’s handling of sync vs async routes is then made concrete. A normal `def` route runs in a separate thread pool so it won’t block the main event loop. An `async def` route runs directly in the event loop, which is more efficient—but only if every I/O operation is properly awaited. If blocking I/O sneaks into an async route (for example, using a synchronous database session or a synchronous HTTP client), it can stall the entire event loop and negate the benefits.

The conversion work starts with database infrastructure. The app moves from SQLAlchemy’s synchronous engine to an async engine using `aiosqlite` as the async driver (via a `+aiosqlite` URL). It replaces the session factory with an async session maker, updates the engine creation to `create_async_engine`, and changes the `get_db` dependency into an async generator using `async with` on the async session. The tutorial also highlights `expire_on_commit=False` as a recommended async setting to avoid problems with expired objects and lazy loading.

Table creation shifts into FastAPI’s `lifespan` function. Because `metadata.create_all` is synchronous, it can’t be called directly with the async engine. Instead, startup uses `engine.begin()` with an async connection and runs the sync create call inside that async context. Shutdown disposes of the engine.

The most critical async SQLAlchemy difference is relationship loading. Lazy loading that works in sync SQLAlchemy fails in async mode (often producing errors like “missing green lit”). The fix is eager loading with `select` and `selectinload` (imported as `select` and `selectinload`). Wherever templates or API responses access related objects—like `post.author`—the queries must include eager loading. For create/update flows, `db.refresh` is used with `attribute_names` (e.g., `author`) so the returned post includes the author relationship without extra queries.

Finally, exception handling is updated to use FastAPI’s default async exception handlers for consistency and less custom code. After installing missing async dependencies (notably `green lit`), the app is tested end-to-end: pages render, API responses include author data, and the external behavior matches the sync version. The tutorial closes by outlining when to use async (multiple concurrent I/O operations, external API/database under load) and when to stick with sync (simple fast operations, CPU work, or when relying on sync-only libraries), while noting that mixed approaches are normal in real systems.

Cornell Notes

Async in FastAPI is most valuable for I/O-bound workloads where requests spend time waiting on external systems like databases or network calls. Sync (`def`) routes run in a thread pool, while async (`async def`) routes run in the event loop and must `await` all I/O to avoid blocking. Converting the app required switching SQLAlchemy to an async engine and session (using `aiosqlite`), moving table creation into a `lifespan` startup routine, and replacing relationship lazy loading with eager loading via `select` + `selectinload`. Because async SQLAlchemy can’t lazy load relationships, queries and `db.refresh(..., attribute_names=...)` must explicitly load related data so API responses and templates still include fields like `post.author`.

Why doesn’t async automatically make an app faster?

Async mainly reduces idle time during I/O waits. If a request is doing CPU-bound work (heavy calculations, image processing, data crunching), the CPU stays busy and there’s nothing for async to optimize. For simple, single requests, the overhead of async machinery can even make performance slightly worse. The benefit shows up under concurrent load—many requests waiting on the same kinds of external I/O (database queries, network calls, file operations).

How does FastAPI treat `def` routes versus `async def` routes?

A `def` route is executed in a separate thread pool, preventing it from blocking the main event loop. An `async def` route runs directly in the event loop, which is more efficient, but it requires that all I/O be non-blocking and awaited. If blocking I/O is used inside an async route (for example, a synchronous database session or a synchronous HTTP client), the event loop can stall and performance can degrade.

What breaks when moving to async SQLAlchemy, and why?

Lazy loading of relationships stops working in async SQLAlchemy. In sync mode, accessing something like `post.author` can trigger an automatic query. In async mode, that lazy loading would require running a synchronous query inside an async context, which isn’t allowed—leading to errors such as “missing green lit.” The fix is eager loading relationships in the original query using `select` plus `selectinload` (or similar eager-loading options).

How should relationship data be loaded for templates and API responses in async mode?

Any query that will later access relationships must eager load them. For example, when rendering templates that use `post.author`, the query fetching posts must include eager loading options like `selectinload(models.post.author)`. For create/update endpoints, `db.refresh` can also be used with `attribute_names` (e.g., `author`) so the returned `post` object already has the related author loaded without relying on lazy loading.

Why move table creation into `lifespan` instead of calling `metadata.create_all` directly?

`metadata.create_all` is synchronous. With an async engine, calling it directly would mix sync operations into async startup incorrectly. The tutorial deletes the direct call and instead defines an async `lifespan` context manager. During startup, it uses `engine.begin()` to get an async connection and runs the synchronous create call inside that async context. On shutdown, it disposes of the engine.

What’s the difference between awaiting `db.commit()`/`db.refresh()` and not awaiting `db.add()`?

`db.add()` only places the object into the session’s pending state in memory; it doesn’t perform I/O. The actual database interaction happens during `commit` and `refresh`, which do require waiting. That’s why `await db.commit()` and `await db.refresh(...)` are used, while `db.add(...)` is called without `await`.

Review Questions

  1. When would switching a FastAPI route from `def` to `async def` likely improve throughput, and when might it not?
  2. What specific async SQLAlchemy rule forces eager loading, and how do `selectinload` and `db.refresh(..., attribute_names=...)` address it?
  3. During the async conversion, why is table creation handled in `lifespan`, and what problem would occur if `metadata.create_all` were called directly with the async engine?

Key Points

  1. 1

    Async improves performance mainly for I/O-bound workloads under concurrent load, not for CPU-bound computation.

  2. 2

    FastAPI runs `def` routes in a thread pool, while `async def` routes run in the event loop and must await non-blocking I/O.

  3. 3

    Async SQLAlchemy does not support lazy loading of relationships; eager loading is required to avoid errors like “missing green lit.”

  4. 4

    Switching to async SQLAlchemy involves using an async driver (e.g., `aiosqlite`), `create_async_engine`, an async session maker, and an async `get_db` dependency.

  5. 5

    Table creation must move into an async `lifespan` startup routine because `metadata.create_all` is synchronous.

  6. 6

    Relationship data in responses/templates must be loaded via `select` + `selectinload` and, after writes, via `db.refresh` with `attribute_names`.

  7. 7

    Exception handling should use FastAPI’s default async handlers to keep behavior consistent and reduce custom JSON logic.

Highlights

Async doesn’t speed up CPU work; it reduces idle time during database/network/file waits, especially when many requests arrive at once.
In async SQLAlchemy, lazy loading fails—relationship fields like `post.author` must be eager loaded with `selectinload` or loaded via `db.refresh(..., attribute_names=...)`.
FastAPI’s `lifespan` is the right place to run startup table creation when using an async engine, because direct synchronous `create_all` can’t be called naively.

Topics

  • Async vs Sync Routes
  • Async SQLAlchemy
  • Eager Loading
  • FastAPI Lifespan
  • I/O Bound Performance

Mentioned