async def and await — Writing Asynchronous Functions

The async def and await keywords are Python’s syntax for writing asynchronous code that reads almost like synchronous code. An async def function defines a coroutine — a function that can be paused and resumed. The await keyword pauses the coroutine until the awaited operation completes, yielding control back to the event loop so it can run other coroutines in the meantime. These two keywords are the entire public API for writing async Python — everything else (tasks, queues, semaphores) builds on top of them.

Coroutines — async def and await

import asyncio

# ── Regular function vs coroutine function ────────────────────────────────────
def regular():
    return "sync result"

async def coroutine():
    return "async result"

# Calling a regular function → executes immediately, returns the value
result = regular()       # "sync result"

# Calling a coroutine function → returns a COROUTINE OBJECT (not the result yet)
coro = coroutine()       # <coroutine object coroutine at 0x...>
print(type(coro))        # <class 'coroutine'>

# To actually run the coroutine, you need the event loop:
result = asyncio.run(coroutine())   # "async result"

# ── await suspends until the awaited coroutine completes ─────────────────────
async def fetch_data(delay: float, value: str) -> str:
    await asyncio.sleep(delay)   # non-blocking wait — yields to event loop
    return value

async def main():
    result = await fetch_data(0.5, "hello")   # wait for it to complete
    print(result)   # "hello"

asyncio.run(main())
Note: A coroutine function (defined with async def) and a coroutine object (created by calling that function) are different things. async def greet(): return "hi" defines the function. greet() creates a coroutine object — the function body has not run yet. await greet() or asyncio.run(greet()) actually executes the body. Forgetting to await a coroutine is a common bug — the coroutine object is created but the code never runs. Python 3.11+ warns about unawaited coroutines.
Tip: You can only use await inside an async def function — never at the top level of a module or inside a regular function. In FastAPI, all route handlers that do async work must be defined as async def. The rule is simple: if you need to await anything, the function must be async def. If you do not await anything, use a regular def (FastAPI runs it in a thread pool automatically).
Warning: await does NOT mean “run this concurrently.” It means “wait for this coroutine to finish before continuing.” Sequential await calls are still sequential — they just yield to the event loop while waiting instead of blocking the thread. To run coroutines concurrently, use asyncio.gather() or asyncio.create_task() (covered in the next lesson).

The Awaitable Protocol

import asyncio

# You can await: coroutines, Tasks, Futures, and objects with __await__

# ── Coroutine ─────────────────────────────────────────────────────────────────
async def greet(name: str) -> str:
    await asyncio.sleep(0.1)
    return f"Hello, {name}!"

async def main():
    # Sequential — greet() runs, then we proceed
    msg = await greet("Alice")
    print(msg)   # "Hello, Alice!"

    # Chain awaits
    user    = await fetch_user(1)
    posts   = await fetch_posts(user["id"])
    return posts

# ── asyncio.sleep() — the async equivalent of time.sleep() ────────────────────
async def demo():
    print("Start")
    await asyncio.sleep(1.0)   # yield for 1 second — other coroutines can run
    print("After 1 second")

# ── What happens step by step ─────────────────────────────────────────────────
# 1. asyncio.run(main()) creates the event loop and schedules main()
# 2. main() runs until it hits 'await greet("Alice")'
# 3. greet() runs until it hits 'await asyncio.sleep(0.1)'
# 4. Sleep yields to event loop — event loop runs other pending coroutines
# 5. After 0.1s, sleep completes, greet() resumes, returns "Hello, Alice!"
# 6. main() resumes with msg = "Hello, Alice!"

async/await with Real I/O

import asyncio
import httpx

# ── Async HTTP with httpx ──────────────────────────────────────────────────────
async def fetch_post(post_id: int) -> dict:
    async with httpx.AsyncClient() as client:
        response = await client.get(f"https://jsonplaceholder.typicode.com/posts/{post_id}")
        response.raise_for_status()
        return response.json()

async def main():
    post = await fetch_post(1)
    print(post["title"])

asyncio.run(main())

# ── Async database (asyncpg example) ──────────────────────────────────────────
import asyncpg

async def get_users_from_db(pool: asyncpg.Pool) -> list:
    async with pool.acquire() as connection:
        rows = await connection.fetch("SELECT id, name, email FROM users LIMIT 10")
        return [dict(row) for row in rows]

async def setup():
    pool = await asyncpg.create_pool(dsn="postgresql://localhost/mydb")
    users = await get_users_from_db(pool)
    await pool.close()
    return users

Rules of async/await

Rule Correct Wrong
await inside async def only async def f(): await coro() def f(): await coro() → SyntaxError
Call async function with await result = await async_func() result = async_func() → coroutine object
Run from sync code asyncio.run(main()) main() → nothing runs
Can call sync from async async def f(): sync_func() Only avoid if sync_func blocks I/O
await blocks current coro only Other coroutines can run during await Does NOT block other coroutines

Common Mistakes

Mistake 1 — Forgetting to await a coroutine

❌ Wrong — coroutine object created but never executed:

async def main():
    result = fetch_data(1)   # forgot await — result is a coroutine, not data!
    print(result)            # <coroutine object fetch_data ...>

✅ Correct:

async def main():
    result = await fetch_data(1)   # ✓ actually runs and returns data
    print(result)

Mistake 2 — Using await outside async def

❌ Wrong — SyntaxError:

def process():
    data = await fetch_data(1)   # SyntaxError: 'await' outside async function

✅ Correct — make the function async:

async def process():
    data = await fetch_data(1)   # ✓

Mistake 3 — Confusing sequential await with concurrent execution

❌ Wrong — expects both to run simultaneously:

async def main():
    result1 = await fetch(url1)   # waits for url1 to complete
    result2 = await fetch(url2)   # THEN waits for url2 — still sequential!

✅ Correct — use asyncio.gather() for concurrent execution (next lesson):

async def main():
    result1, result2 = await asyncio.gather(fetch(url1), fetch(url2))   # concurrent ✓

Quick Reference

Pattern Code
Async function async def f(): ...
Await a coroutine result = await coro()
Run from sync asyncio.run(main())
Non-blocking sleep await asyncio.sleep(seconds)
Async HTTP GET await httpx.AsyncClient().get(url)
Check if coroutine asyncio.iscoroutine(obj)
Check if coroutine func asyncio.iscoroutinefunction(func)

🧠 Test Yourself

You write result = my_async_function() instead of result = await my_async_function(). What does result contain, and what happened to the function’s body?