async / await

The async and await keywords are Python's syntax for defining and driving coroutines -- functions that can pause at well-defined points and resume later without blocking the whole program.

async and await are language keywords (introduced as dedicated syntax in Python 3.5, via PEP 492) for writing coroutine-based code. async def marks a function as a coroutine function; await is used inside such a function to pause execution at a well-defined point until an awaited operation completes, handing control back to the event loop in the meantime.

Async Functions

Prefixing a function definition with async (async def name(...): ...) makes it a coroutine function. Calling it does not execute the function body immediately the way calling an ordinary function does -- instead, it returns a coroutine object representing the not-yet-run (or partially run) computation. The body only executes when that coroutine is driven forward, typically via await or by being scheduled as a Task.

import asyncio

async def get_greeting(name: str) -> str:
    return f"Hello, {name}!"

async def main() -> None:
    coro = get_greeting("Grace")   # no body execution happens yet
    print(type(coro))              # <class 'coroutine'>
    result = await coro            # NOW the body runs, and we get the return value
    print(result)                  # "Hello, Grace!"

asyncio.run(main())

Coroutine Objects

A coroutine object is the runtime representation of a paused or in-progress async def function call. It supports being driven forward step by step (that's what await and the event loop do internally) and, like a generator, it can only be awaited once -- awaiting an already-exhausted coroutine raises a RuntimeError.

Under the hood, coroutines are closely related to generators (both are built on the same suspend/resume machinery in CPython), but they are a distinct type intended specifically for the async/await protocol rather than for iteration with a plain for loop.

await

await expr does two things: it runs (or resumes) the awaitable given by expr until either it completes and produces a value, or it needs to pause because it's waiting on something (like I/O); if it needs to pause, control returns to the event loop so other pending coroutines can make progress, and this coroutine resumes automatically once the awaited operation becomes ready. await can only appear inside an async def function body -- using it elsewhere is a SyntaxError.

Because each await is sequential here (step_two waits on step_one's result), they run one after another, not concurrently -- concurrency requires explicitly running multiple coroutines together, e.g. via asyncio.gather() or separate Tasks (see the asyncio topic).

import asyncio

async def step_one() -> int:
    print("step one starting")
    await asyncio.sleep(1)
    print("step one done")
    return 1

async def step_two(prev: int) -> int:
    print("step two starting")
    await asyncio.sleep(1)
    print("step two done")
    return prev + 1

async def main() -> None:
    a = await step_one()       # main pauses here until step_one finishes
    b = await step_two(a)      # then pauses here until step_two finishes
    print(f"final: {b}")

asyncio.run(main())

Suspension

Suspension is the act of a coroutine pausing at an await point, saving its local execution state (much like a generator's suspended state), and returning control to whatever is driving it (the event loop, or an outer coroutine that awaited it). Suspension only happens at explicit await expressions -- ordinary statements in between awaits run to completion without interruption, which is what 'cooperative' scheduling means (see the Async Programming topic).

What actually triggers suspension is the awaited object's own __await__ implementation signaling 'not ready yet' -- for example, asyncio.sleep() schedules a wake-up with the event loop and immediately yields control; a network read awaitable yields until the socket has data.

Resumption

Resumption is the event loop calling back into a suspended coroutine once whatever it was waiting on becomes ready (a timer elapses, a socket becomes readable, another awaited coroutine finishes). Execution picks up exactly where it left off, with all local variables intact -- from the coroutine's own point of view, await asyncio.sleep(1) simply 'took a while to return', even though in reality the entire event loop was busy running other tasks during that pause.

import asyncio

async def worker(name: str) -> None:
    for i in range(3):
        print(f"{name}: iteration {i}")
        await asyncio.sleep(0.5)  # suspend here; resumes after ~0.5s

async def main() -> None:
    await asyncio.gather(worker("A"), worker("B"))
    # Output interleaves: A and B take turns at each suspension point
    # A: iteration 0
    # B: iteration 0
    # A: iteration 1
    # B: iteration 1
    # ...

asyncio.run(main())

Non-Blocking Design

The entire value proposition of async/await rests on non-blocking I/O: the operations you await (network calls via asyncio streams, asyncio.sleep(), async database drivers, etc.) must themselves be implemented so that, instead of blocking the whole thread while waiting, they register interest with the event loop (e.g. 'wake me when this socket has data') and yield control immediately. If you await something that's secretly implemented as a blocking call under the hood, you get none of async's benefit -- the whole event loop still stalls.

This is why ordinary synchronous libraries (a classic blocking HTTP client, standard blocking file I/O, time.sleep()) don't automatically 'become async' just by calling them from inside an async def function -- true non-blocking behavior requires either an async-native library, or offloading the blocking call to a thread pool via asyncio.to_thread() / loop.run_in_executor() so it doesn't stall the event loop.

Rules and Behavior

A few syntax/semantic rules worth being explicit about: async def functions can use return value normally (it becomes the coroutine's result once awaited); they can also contain async for and async with (see the Async Context Managers & Iterators topic) in addition to ordinary for/with; a function that uses yield inside async def becomes an async generator (a distinct concept, producing an async iterator, not a coroutine that returns a single value) -- mixing return value (non-None) and yield in the same async def is not allowed, same as with regular generators.

Practical Usage

Realistic async code almost always involves running multiple coroutines concurrently rather than one straight sequence of awaits (which offers no concurrency benefit over ordinary synchronous code) -- use asyncio.gather(), asyncio.create_task(), or asyncio.TaskGroup (3.11+) to actually overlap waiting periods. See the dedicated asyncio topic for these APIs.

import asyncio

async def fetch_page(url: str) -> str:
    await asyncio.sleep(1)  # simulate network latency
    return f"<html>content of {url}</html>"

async def main() -> None:
    urls = ["a.example", "b.example", "c.example"]
    # Sequential (slow): total time ~= 3 seconds
    # for url in urls:
    #     print(await fetch_page(url))

    # Concurrent (fast): total time ~= 1 second
    pages = await asyncio.gather(*(fetch_page(u) for u in urls))
    for page in pages:
        print(page)

asyncio.run(main())

Edge Cases and Pitfalls

Forgetting await before a coroutine call is one of the most common async bugs -- the code doesn't error immediately, it just silently does nothing useful (you get a coroutine object where you expected a result), often surfacing later as a confusing RuntimeWarning: coroutine was never awaited or a type error when the unawaited coroutine object is used where a real value was expected. Calling a synchronous, blocking function from inside a coroutine without awaiting or offloading it doesn't raise an error either -- it just quietly blocks the entire event loop for that duration, which can be hard to notice in small tests but devastating under real concurrent load.

Complete Example

import asyncio
import time

async def simulate_download(name: str, seconds: float) -> str:
    print(f"[{name}] download starting")
    await asyncio.sleep(seconds)
    print(f"[{name}] download complete")
    return f"{name}.zip"

async def main() -> None:
    start = time.perf_counter()
    files = await asyncio.gather(
        simulate_download("report", 1.0),
        simulate_download("dataset", 2.0),
        simulate_download("logo", 0.5),
    )
    elapsed = time.perf_counter() - start
    print(f"downloaded: {files}")
    print(f"total time: {elapsed:.1f}s")  # ~2.0s -- limited by the slowest download

asyncio.run(main())

Mental Model

Think of an async def function as a recipe with clearly marked 'while this simmers, go do something else' instructions (the await points). The cook (event loop) follows the recipe until hitting such an instruction, then checks on other simmering dishes, and comes back to resume exactly where they left off once that step is ready. Ordinary function calls, by contrast, are recipes with no such marked breaks -- the cook must stand there and finish the whole thing before doing anything else.

Quick Reference

async def f(...): ...     - defines a coroutine function
f(...)                     - returns a coroutine object (body not yet run)
await coro                 - runs/resumes it; suspends here if it must wait
await coro                 - only valid inside another async def
asyncio.run(main())        - entry point: creates event loop, runs main() to completion
asyncio.gather(c1, c2, ..) - run multiple awaitables concurrently
asyncio.to_thread(fn, ...) - offload a blocking call so it doesn't stall the loop

Key Takeaways

async def defines a coroutine function; calling it returns a coroutine object without running the body -- the body runs only once awaited or scheduled.

await pauses execution at that point until the awaited operation is ready, yielding control back to the event loop so other tasks can progress.

Suspension and resumption happen only at explicit await points, preserving cooperative scheduling.

async/await's benefit depends entirely on the awaited operations being genuinely non-blocking; a hidden blocking call still stalls the whole event loop.

A straight sequence of awaits runs sequentially, not concurrently -- real concurrency needs asyncio.gather(), tasks, or a TaskGroup.

Forgetting await on a coroutine call is a common, often silent bug.

async def get_data(): return 42 async def main(): data = get_data() # forgot await -- data is a coroutine object, not 42 print(data + 1) # TypeError: unsupported operand type(s) async def get_data(): return 42 async def main(): data = await get_data() # runs the coroutine, gets the actual value print(data + 1) # 43

Forgetting await leaves you holding a coroutine object instead of its eventual result; you must await it to actually run the body and retrieve the value.

import time async def process(): time.sleep(2) # blocking call -- stalls the ENTIRE event loop return "done" import asyncio async def process(): await asyncio.sleep(2) # non-blocking; other tasks keep progressing return "done"

A blocking call inside a coroutine gives the event loop no chance to run anything else during that time, eliminating the concurrency async/await is meant to provide.

async def main(): result_a = await fetch_a() # waits fully before starting fetch_b result_b = await fetch_b() # sequential: total time = time(a) + time(b) async def main(): result_a, result_b = await asyncio.gather(fetch_a(), fetch_b()) # concurrent: total time approx max(time(a), time(b))

Awaiting coroutines one after another runs them sequentially; use asyncio.gather() (or separate Tasks) to actually overlap their waiting periods and gain concurrency.

What is returned by calling an async def function, and when does its body actually execute?

Calling it returns a coroutine object; the body only executes once that coroutine is awaited (or otherwise scheduled and driven by the event loop, e.g. as a Task).

Where is await legal to use, and what happens if you try to use it elsewhere?

Only inside an async def function body; using await outside one (e.g. in an ordinary function or at plain module top-level) is a SyntaxError.

What actually happens when a coroutine hits an await expression whose target isn't ready yet?

The coroutine suspends, saving its local execution state, and control returns to whatever is driving it (typically the event loop), which can run other ready tasks; the coroutine resumes automatically once the awaited operation becomes ready.

Why does awaiting several coroutines one after another (a, then b, then c) not provide any concurrency benefit?

Each await fully completes before the next line runs, so the coroutines execute sequentially, one at a time, with no overlap in their waiting periods -- true concurrency requires running them together via something like asyncio.gather() or separate Tasks.

Why does calling a blocking synchronous function inside an async def coroutine defeat the purpose of using async/await?

A blocking call does not yield control back to the event loop; it occupies the single thread for its entire duration, freezing every other pending coroutine, which eliminates the concurrency async/await exists to provide.

What is the difference between a coroutine and an async generator, syntactically?

An async def function using only return produces a coroutine (a single eventual result); an async def function containing yield produces an async generator (an async iterable that yields a sequence of values over time); mixing a non-None return with yield in the same function isn't allowed.

What commonly happens (as a bug) if you forget the await keyword before calling a coroutine function?

You get a coroutine object instead of the value you expected -- often with no immediate error, just a silent no-op, sometimes surfacing later as a RuntimeWarning ('coroutine was never awaited') or a TypeError when that coroutine object is used in place of a real value.

Write a coroutine that awaits two sub-coroutines sequentially and prints how long that took versus running them concurrently.

import asyncio import time async def work(seconds: float) -> None: await asyncio.sleep(seconds) async def sequential() -> float: start = time.perf_counter() await work(1) await work(1) return time.perf_counter() - start async def concurrent() -> float: start = time.perf_counter() await asyncio.gather(work(1), work(1)) return time.perf_counter() - start async def main() -> None: print(f"sequential: {await sequential():.1f}s") # ~2.0s print(f"concurrent: {await concurrent():.1f}s") # ~1.0s asyncio.run(main()) sequential() awaits each work() call one after another, so their wait times add up (~2s total); concurrent() runs both together with gather(), so they overlap and the total is close to the longer individual wait (~1s).

What does print(c) show, and why not "value"?

It prints a repr like <coroutine object make_coro at 0x...>, not "value" -- calling make_coro() only creates the coroutine object; its body (and thus the return "value") only runs once the coroutine is awaited, which never happens in this snippet.

Write a coroutine function that yields values over time (an async generator) versus one that returns a single value (a coroutine), and explain the difference.

import asyncio async def single_result() -> int: await asyncio.sleep(0.1) return 42 # a coroutine: produces ONE eventual value async def stream_of_results(): for i in range(3): await asyncio.sleep(0.1) yield i # an async generator: produces a SEQUENCE of values over time async def main() -> None: print(await single_result()) # 42 async for value in stream_of_results(): # requires 'async for', see next topic print(value) # 0, 1, 2 asyncio.run(main()) single_result uses return and is a normal coroutine -- awaiting it once gives a single final value. stream_of_results uses yield inside async def, making it an async generator -- it must be driven with 'async for' (covered in the Async Context Managers & Iterators topic) and produces a sequence of values, one per iteration, each possibly after further awaiting.

async def make_coro():
    return "value"

c = make_coro()
print(c)