Awaiting Tasks in Python asyncio
python await task: Learn how to await asyncio tasks correctly, retrieve results, handle exceptions, cancel tasks, and avoid common concurrency pitfalls.
python await task requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.
When you write asynchronous Python code with asyncio, the await keyword is how you suspend the current coroutine until an awaitable completes. The most common awaitable you will work with is a task created by asyncio.create_task(). Understanding how to await a task properly is the difference between a responsive concurrent application and one that silently blocks or leaks exceptions.
What Is an asyncio Task and Why Await It?
An asyncio task is a wrapper around a coroutine that schedules it to run on the event loop. When you call asyncio.create_task(coro), the coroutine is scheduled for execution but does not run immediately. The task runs concurrently with other tasks when the event loop gets control. Awaiting a task suspends the current coroutine until the task finishes, then returns its result or raises its exception.
Tasks are the primary way to run multiple coroutines concurrently. Without tasks, you would have to await each coroutine sequentially, which defeats the purpose of async code. Awaiting a task is how you integrate its outcome into your current flow while allowing other tasks to progress in the meantime.
Creating a Task with asyncio.create_task()
Before you can await a task, you need to create one. The canonical way is asyncio.create_task(), available since Python 3.7. It takes a coroutine and returns a Task object. Here is a minimal example:
import asyncio async def fetch_data(): await asyncio.sleep(1) return "data" async def main(): task = asyncio.create_task(fetch_data()) result = await task print(result) asyncio.run(main())
In this example, task is created and scheduled. The await task suspends main() until fetch_data() completes, then assigns the return value to result. The event loop runs fetch_data() concurrently with anything else that is ready, but in this simple case there is nothing else.
Awaiting a Task and Getting Its Result
The await expression on a task returns the coroutine's return value. If the coroutine raises an exception, that exception is re-raised at the await point. This behavior is identical to awaiting the coroutine directly, but with the added benefit that the task can be cancelled or awaited from multiple places.
You can also check whether a task is done without blocking by using task.done(), but that is not the same as awaiting. Awaiting always waits until completion. If you need to wait with a timeout, use asyncio.wait_for() or asyncio.timeout() (Python 3.11+).
async def main(): task = asyncio.create_task(fetch_data()) try: result = await asyncio.wait_for(task, timeout=2) except asyncio.TimeoutError: print("Task took too long")
When a timeout occurs, the task is cancelled automatically. This is a common pattern for bounding the time you spend waiting on a task.
Handling Exceptions When Awaiting a Task
If a task raises an exception, awaiting it propagates that exception to the awaiting coroutine. You must handle it or it will crash your program. The typical approach is a try/except block around the await:
async def risky_task(): raise ValueError("bad data") async def main(): task = asyncio.create_task(risky_task()) try: await task except ValueError as e: print(f"Caught: {e}")
If you never await a task that raises an exception, asyncio will log an "exception was never retrieved" warning. This happens because the task's exception is stored internally and only re-raised when the task is awaited. To avoid this, always ensure a task is awaited or explicitly call task.exception() to retrieve the exception.
Awaiting Multiple Tasks with asyncio.gather()
When you have several independent tasks, awaiting them one by one is inefficient. asyncio.gather() runs them concurrently and collects results in order. It returns a list of results when all tasks complete, or raises the first exception if any task fails.
async def fetch_all(): tasks = [asyncio.create_task(fetch_data()) for _ in range(3)] results = await asyncio.gather(*tasks) return results
gather() also accepts coroutines directly, but creating tasks explicitly gives you more control over cancellation and early exit. If one task fails, gather() cancels the others by default. Use return_exceptions=True to collect exceptions instead:
results = await asyncio.gather(*tasks, return_exceptions=True)
This returns a list where exceptions are stored as objects rather than being raised, letting you inspect each outcome individually.
Task Cancellation and Timeouts
Awaiting a task does not prevent it from being cancelled. You can cancel a task with task.cancel(), which raises asyncio.CancelledError inside the coroutine. When you await a cancelled task, the CancelledError is raised at the await point. This is useful for graceful shutdown or for stopping work that is no longer needed.
async def main(): task = asyncio.create_task(fetch_data()) task.cancel() try: await task except asyncio.CancelledError: print("Task was cancelled")
If you need to protect a task from cancellation while still allowing it to be awaited, use asyncio.shield(). This wraps the task so that cancelling the wrapper does not cancel the underlying task. This is useful for cleanup operations that must run to completion.
Common Pitfalls When Awaiting Tasks
One frequent mistake is forgetting to await a task and letting it run in the background without ever checking its result. This can lead to unhandled exceptions or tasks that outlive their purpose. Another pitfall is blocking the event loop with synchronous code inside a coroutine. Even if you await a task, the event loop cannot run other tasks while you are blocked in a time.sleep() or a CPU-heavy loop. Use asyncio.sleep() and offload CPU-bound work to a thread pool.
A more subtle issue is awaiting the same task from multiple coroutines. A task can be awaited multiple times, but it will only run once. If you need to share the result, store it in a variable or use a future. Also, be careful with asyncio.gather() when you pass coroutines directly: they are automatically wrapped as tasks, but you lose the ability to cancel them individually before the gather starts.
Performance Considerations of Awaiting Tasks
Awaiting a task has minimal overhead—it is just a suspension and resumption of the coroutine. The real cost is the number of context switches and the scheduling overhead of the event loop. Creating too many tasks can exhaust memory or cause excessive switching. In practice, you should structure your concurrency around the number of I/O-bound operations, not create a task for every tiny piece of work.
When awaiting many tasks, asyncio.gather() is more efficient than awaiting them in a loop because it schedules all tasks at once and waits for the group. If you need to process results as they arrive, use asyncio.as_completed() instead, which yields tasks in the order they finish. This allows you to handle each result immediately without waiting for the slowest task.
async def main(): tasks = [asyncio.create_task(fetch_data(i)) for i in range(5)] for coro in asyncio.as_completed(tasks): result = await coro print(result)
This pattern is ideal for streaming results or when you want to start processing early. The key is to choose the right tool for your concurrency pattern: gather for all-or-nothing, as_completed for incremental processing, and direct await for a single task whose result you need immediately.