The previous lesson covered the event loop conceptually; this one covers the syntax you actually write. async/await looks deceptively like ordinary synchronous code, which is exactly why its few sharp edges ā a forgotten await, calling a coroutine without running it ā are so easy to trip over.
1A Coroutine Function Behaves Like a Generator Function
async def greet(name: str) -> str: defines a coroutine function ā and calling it, greet("Ada"), follows the exact same pattern established in the Generators lesson: the function body doesn't execute immediately. Instead, calling it returns a coroutine object, an inert representation of 'this computation, not yet started', which only actually runs once something explicitly drives it forward.
That 'something' is await. await greet("Ada"), used inside another async def function, is what actually executes greet's body, suspending the *calling* coroutine at that point (yielding control back to the event loop, exactly as covered in the previous lesson) until greet completes and produces its return value ā which await then hands back as its own expression value, letting you write result = await greet("Ada") and use result exactly like an ordinary function's return value.
await can only appear inside another async def function (or directly at a REPL/notebook top level, a special case some environments support) ā you cannot await something from ordinary synchronous code. The one sanctioned bridge from synchronous to asynchronous code is asyncio.run(main()), which creates an event loop, runs the given coroutine to completion, and is meant to be called exactly once, at your program's actual entry point.
async def greet(name: str) -> str:
return f"Hello, {name}!"
result = greet("Ada")
print(result) # <coroutine object greet at 0x...> -- NOT 'Hello, Ada!'
print(type(result)) # <class 'coroutine'>await greet("Ada") ā 'Hello, Ada!' ā actually runs it
2The Silent Bug: A Forgotten await
Because calling a coroutine function without await is syntactically valid ā it's just an expression that happens to produce a coroutine object ā Python cannot treat a missing await as a hard error the way it would a genuine syntax mistake. greet("Ada") on its own line, inside main(), is legal Python: it creates a coroutine object and then, since nothing captures or awaits it, that object is immediately eligible for garbage collection, and greet's body never runs at all.
CPython does emit RuntimeWarning: coroutine 'greet' was never awaited when this happens (detected when the unused coroutine object is garbage collected), which is a genuinely useful diagnostic ā but warnings are easy to miss in noisy log output, and by default don't halt execution or fail a test suite the way an exception would. This makes 'forgot an await' one of the most common asyncio bugs in practice: the code runs without crashing, some side effect just silently never happens.
The practical defense is largely tooling-based: ruff and other linters can statically flag a coroutine-returning call whose result is neither awaited nor otherwise used, catching the mistake before runtime; treating RuntimeWarnings as errors in test configuration (-W error::RuntimeWarning in pytest) turns the silent runtime warning into an immediate, loud test failure instead.
async def main():
result = await greet("Ada") # actually runs greet(), waits for it, gets the return value
print(result) # 'Hello, Ada!'
asyncio.run(main()) # the ONE place you bridge sync code into async codeRuntimeWarning, but no crash ā the body simply never ran
3create_task: Starting Work Without Immediately Waiting
await coroutine runs a coroutine *and waits for it to finish* before your own code continues ā sequential from the calling coroutine's point of view, even though other tasks can interleave in the meantime via the event loop. Sometimes you want to start a coroutine running *now*, continue doing other work, and only wait for its result later ā exactly the shape asyncio.gather uses internally to run multiple coroutines concurrently.
asyncio.create_task(greet("Ada")) schedules the coroutine to start running on the event loop immediately (at the next opportunity the loop gets), and returns a Task object right away, without blocking. Your code can then do other work ā including starting more tasks ā and only actually wait for the result when it calls await task, at which point it either gets the already-completed result immediately, or suspends until the task finishes, exactly like awaiting the original coroutine directly would.
This distinction ā await a coroutine directly (sequential) versus create_task() then await the task later (concurrent) ā is the actual mechanism asyncio.gather builds on, and understanding it directly clarifies exactly why gather(fetch("A"), fetch("B")) runs both concurrently: internally, it wraps each coroutine in a task, letting all of them start running before waiting on any of their results.
async def main():
greet("Ada") # BUG: missing await -- greet() never actually runs!
# RuntimeWarning: coroutine 'greet' was never awaited
asyncio.run(main())Starts immediately ā await task later retrieves the result
4Step-by-Step Breakdown
Forgetting a single 'await' is the single most common asyncio bug ā and Python won't always tell you loudly when it happens. Let's build the muscle memory to catch it.
async def creates a coroutine FUNCTION. Calling it does NOT run the body -- it returns a coroutine OBJECT, similar to how calling a generator function doesn't run it either.
Checkpoint: What does calling greet("Ada") return, without await, where greet is async def?
- āA coroutine object ā the function body has not run yet
- āThe string 'Hello, Ada!' ā the function runs immediately
await is what actually RUNS the coroutine and gets its result -- and it only works inside another async function (or at the top level in a REPL/notebook).
A forgotten await doesn't crash loudly -- it just silently returns a coroutine object instead of running it, which Python often warns about but easy to miss.
Checkpoint: What actually happens if you write greet("Ada") inside an async function without await?
- āgreet's body never runs at all ā a coroutine object is created and immediately discarded
- āgreet runs synchronously as a normal side effect, just without returning its value
asyncio.create_task() schedules a coroutine to run CONCURRENTLY, without immediately awaiting it -- letting you start work and check on it later.
With individual coroutines under control, Task Groups give you structured, safer patterns for running many of them together.
Inspect a Real Coroutine Object. Finish check_coroutine_type(): calling an async function returns a coroutine object without running it.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Enable linter/test-suite checks that catch a coroutine call missing await
Since a missing await fails silently (a RuntimeWarning, not an exception), configure ruff and pytest's warning filters to treat it as an error so the bug is caught immediately, not discovered later as a mysteriously-missing side effect.
Use asyncio.create_task() when you need to start work and continue immediately, await directly when you need the result before proceeding
Reaching for create_task() unnecessarily adds complexity for genuinely sequential logic; reaching for direct await when you actually needed concurrency silently serializes work that could have overlapped.
Frequent Bugs
Calling a coroutine function without await inside another async function, silently skipping that coroutine's entire body with only a RuntimeWarning (not an exception) as a hint.
Always await a coroutine call (or explicitly schedule it with create_task/gather if concurrency is intended) ā never leave a coroutine-returning call as a bare, unawaited expression statement.
Real-World Examples
Starting a Background Task While Processing a Request
A web request handler needs to log analytics data (which involves a slow network call) without making the user wait for that logging call to complete before receiving their response.
async def handle_request(request):
# Start logging concurrently, don't block the response on it
log_task = asyncio.create_task(log_analytics_event(request))
response_data = await build_response(request)
# Ensure logging completes before the handler function itself returns,
# without having made the RESPONSE wait for it
await log_task
return response_data