Advanced FastAPI Patterns • Lesson 1

Async Endpoints & Concurrency

Understand the difference between sync and async endpoints in FastAPI, and learn when to use each for optimal performance.

🎯 What You'll Learn

  • Understand the difference between def and async def in FastAPI
  • Know when to use sync vs async path operation functions
  • Use asyncio.sleep to simulate non-blocking async work
  • Grasp how the event loop handles concurrent requests

Async Endpoints & Concurrency

What You'll Learn

In this lesson you will explore one of FastAPI's most powerful features: native support for asynchronous Python. You will learn:

  • The difference between def and async def path operation functions
  • How the Python event loop manages concurrent requests
  • When to choose sync vs async endpoints
  • How to perform non-blocking work with asyncio

Theory

Sync vs Async in Python

Python supports two styles of function definitions:

# Synchronous - blocks until complete
def do_work():
    return "done"

# Asynchronous - can be paused and resumed
async def do_work():
    await some_io_operation()
    return "done"

The async def keyword declares a coroutine. Inside a coroutine you can use await to pause execution while waiting for an I/O operation, allowing other tasks to run in the meantime.

How FastAPI Handles Each Type

FastAPI treats def and async def endpoints differently:

Plain def endpoints are run in a thread pool. FastAPI knows that a regular function might contain blocking I/O (like reading a file or calling an external API with a synchronous library), so it offloads the function to a separate thread to avoid blocking the event loop.

@app.get("/sync/")
def sync_endpoint():
    # Runs in a thread pool - safe for blocking I/O
    return {"message": "sync"}

async def endpoints run directly on the event loop. They must not contain blocking operations, but they can use await for non-blocking I/O.

@app.get("/async/")
async def async_endpoint():
    # Runs on the event loop - use await for I/O
    return {"message": "async"}

The Event Loop

The event loop is the core of Python's asyncio framework. Think of it as a task scheduler:

  1. It picks up a task and runs it until it hits an await
  2. When a task awaits, the loop suspends it and picks up the next ready task
  3. When the awaited operation completes, the task is resumed

This means a single thread can handle thousands of concurrent requests, as long as each request spends most of its time waiting for I/O rather than computing.

Simulating Async Work

You can simulate a non-blocking delay with asyncio.sleep():

import asyncio

@app.get("/slow/")
async def slow_endpoint():
    await asyncio.sleep(0.1)  # Non-blocking wait
    return {"message": "done"}

Unlike time.sleep() (which blocks the thread), asyncio.sleep() yields control back to the event loop, allowing other requests to be processed during the wait.

Key Concepts

When to Use async def

Use async def when your endpoint:

  • Calls other async functions with await
  • Uses an async database driver (e.g., asyncpg, motor)
  • Uses an async HTTP client (e.g., httpx.AsyncClient)
  • Performs no blocking I/O at all (simple return)

When to Use Plain def

Use plain def when your endpoint:

  • Calls synchronous blocking libraries
  • Performs CPU-bound computation
  • Uses synchronous database drivers (e.g., psycopg2)
  • Reads or writes files using standard open()

Common Mistake: Blocking in async def

Never do this:

import time

@app.get("/bad/")
async def bad_endpoint():
    time.sleep(5)  # BLOCKS the entire event loop!
    return {"message": "this is bad"}

This would freeze all other requests for 5 seconds because time.sleep() blocks the event loop thread. Use asyncio.sleep() or move blocking work to a plain def endpoint.

Performance Implications

For simple endpoints that just return data without any I/O:

  • async def is slightly faster (no thread pool overhead)
  • def still works perfectly fine

For I/O-heavy endpoints:

  • async def with proper await calls gives the best concurrency
  • def works but each request occupies a thread from the pool

Best Practices

  1. Default to async def for simple endpoints that perform no blocking I/O
  2. Use def when you must call synchronous blocking libraries
  3. Never mix blocking calls inside async def -- move them to def or use asyncio.to_thread()
  4. Use await for every async operation -- forgetting await is a common bug
  5. Keep async endpoints lightweight -- offload heavy CPU work to background tasks or workers
  6. Test both patterns -- ensure your async endpoints actually return correct responses

Additional Resources

💡 Hint

Use 'async def' for endpoints that perform async I/O operations (like awaiting a coroutine). Use plain 'def' for CPU-bound or blocking I/O work. Remember to 'await' async calls like asyncio.sleep().

Ready to Practice?

Now that you understand the theory, let's put it into practice with hands-on coding!

Start Interactive Lesson