Pythonasync / await

async / await

async and await are the two keywords that make asynchronous Python readable as plain, top-to-bottom control flow instead of a tangle of callbacks. This page focuses on the syntax and the mental model — what these keywords actually do to your code's execution. For how the underlying event loop schedules and runs all of this, see the dedicated asyncio page; the two are deeply connected, but it's worth separating "how do I write this" from "what runs it."

Defining a Coroutine with async def

Putting async in front of def changes what the function actually is. A normal function call runs the function body immediately and returns its result. An async def function call does not run the body at all — it immediately returns a coroutine object, a kind of paused, not-yet-started task description.

Python
async def greet():
    print("hello!")

result = greet()
print(type(result))
<class 'coroutine'>

Notice "hello!" never got printed. Creating the coroutine object did not execute the function body — it just packaged it up. To actually run it, the coroutine needs to be awaited or explicitly scheduled on an event loop.

Common Pitfall
Forgetting to await a coroutine is one of the most common mistakes when learning async Python. If you call `greet()` and never await or schedule the result, Python emits a `RuntimeWarning: coroutine 'greet' was never awaited` — and the function body silently never runs. If you see this warning, look for a coroutine call that's missing its `await`.
Using await

await can only appear inside an async def function, and it can only be applied to an "awaitable" — a coroutine, a task, or another object that implements the awaitable protocol. Awaiting does two things at once: it runs the awaited coroutine, and it suspends the current coroutine at that exact point until the awaited one produces a result.

Python
import asyncio

async def greet():
    print("hello!")
    await asyncio.sleep(1)
    print("...and goodbye!")

asyncio.run(greet())
hello!
...and goodbye!

Reading top to bottom, this looks exactly like ordinary synchronous code — that's intentional. The await marks the one spot where this coroutine is willing to pause and let something else run, but the surrounding logic (the print statements, any variables, any if/else branching) reads no differently than regular Python.

Worked Example: Sequential vs Concurrent Awaits

The clearest way to see why await placement matters is to simulate a handful of slow network calls with asyncio.sleep() and compare two ways of running them. First, the naive approach — awaiting each fetch one at a time.

Python
import asyncio
import time

async def fetch_page(name, delay):
    await asyncio.sleep(delay)
    return f"{name} loaded"

async def sequential():
    start = time.perf_counter()
    results = []
    for name, delay in [("page-1", 1), ("page-2", 1), ("page-3", 1)]:
        results.append(await fetch_page(name, delay))
    print(results)
    print(f"sequential took {time.perf_counter() - start:.1f}s")

asyncio.run(sequential())
['page-1 loaded', 'page-2 loaded', 'page-3 loaded']
sequential took 3.0s

Each await inside the loop fully finishes before the next iteration even starts a new fetch, so the three one-second waits stack up to three full seconds. Now schedule all three coroutines up front with asyncio.gather() and await them together.

Python
async def concurrent():
    start = time.perf_counter()
    results = await asyncio.gather(
        fetch_page("page-1", 1),
        fetch_page("page-2", 1),
        fetch_page("page-3", 1),
    )
    print(results)
    print(f"concurrent took {time.perf_counter() - start:.1f}s")

asyncio.run(concurrent())
['page-1 loaded', 'page-2 loaded', 'page-3 loaded']
concurrent took 1.0s

Same three fetches, same one-second delay each, but the wall-clock time drops from 3 seconds to roughly 1 second. Nothing about fetch_page changed — only how the coroutines were awaited. This is the core lesson of async/await: awaiting things one at a time is still sequential, no matter how "async" the individual functions look; overlapping independent waits is what actually buys you concurrency.

Tip
A good rule of thumb: if you find yourself writing `await` inside a loop where each iteration doesn't depend on the previous one's result, that's usually a sign you should collect the coroutines into a list first and run them together with `asyncio.gather()` instead.
Syntax vs Runtime — Where This Page Ends

Everything on this page — async def, coroutine objects, await suspending and resuming — is about the shape of the code you write. None of it explains what actually drives that execution forward, decides which coroutine runs next, or how asyncio.run() and asyncio.gather() are implemented under the hood. That's the event loop's job, and it's covered on the asyncio page. Think of async/await as the grammar, and the event loop as the engine that reads and executes that grammar.

Summary
  • async def defines a coroutine function — calling it creates a coroutine object but does not run the body

  • await runs an awaitable and suspends the current coroutine until it completes, without blocking the whole program

  • A coroutine that's created but never awaited or scheduled produces a "was never awaited" RuntimeWarning and simply never runs

  • Awaiting coroutines one after another in a loop is sequential; use asyncio.gather() to run independent coroutines concurrently

  • This page covers the syntax and control-flow mental model; the asyncio page covers the event loop that actually executes it