Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 25

Concurrent Operations with `asyncio.gather`

Recommended resources

Welcome back! In our last lesson, we explored how asyncio.Task objects are the fundamental units of concurrent work, and you learned to schedule them with asyncio.create_task. We briefly mentioned asyncio.gather as a utility for running multiple tasks. Now, we'll put gather under the microscope.

This lesson is dedicated to mastering asyncio.gather, a high-level and powerful tool for running multiple awaitables concurrently and collecting their results. For a backend developer building microservices, this is a cornerstone pattern. You'll frequently use it to make parallel, independent I/O calls—for example, an API gateway querying several downstream services to assemble a single response.

By the end of this lesson, you will be able to use asyncio.gather to efficiently coordinate and aggregate results from many concurrent operations, including handling partial failures gracefully.

The Power of Gathering: From Sequential to Concurrent

Imagine a scenario where your application needs to fetch data from three different, independent network endpoints. In a synchronous world, you would make these calls one after another, and the total time would be the sum of all individual call durations.

With asyncio, we can do much better. asyncio.gather allows us to "gather" all these operations and run them concurrently. The event loop executes them all "at the same time," switching between them during their I/O wait periods. The total time is then limited only by the single longest operation, not the sum of all of them.

This image perfectly illustrates the performance gain:

Sequential vs. Concurrent Execution with asyncio.gather
This diagram compares the execution time of four tasks run sequentially versus concurrently. On the left, sequential execution sums the time of all tasks. On the right, `asyncio.gather` runs them concurrently, and the total time is determined by the longest-running task.

Let's watch a practical demonstration of this. The following video compares fetching multiple items from an API sequentially versus concurrently with gather, measuring the significant speedup.

Next-Level Concurrent Programming In Python With Asyncio

The video "Next-Level Concurrent Programming In Python With Asyncio" by ArjanCodes provides a clear, practical example of gather.

Watch the segment from sequential vs. concurrent requests. Pay close attention to how a simple for loop of await calls results in slow, sequential execution, while wrapping the calls in asyncio.gather leads to a massive performance improvement.


How to Use asyncio.gather

The basic syntax for gather is straightforward. You pass it a series of awaitable objects (coroutines or tasks).

import asyncio

async def my_coroutine(id, delay):
    await asyncio.sleep(delay)
    return f"Result from {id}"

async def main():



    # gather takes coroutine objects as arguments
    results = await asyncio.gather(
        my_coroutine(1, 1.5),
        my_coroutine(2, 0.5),
        my_coroutine(3, 1.0)
    )
    print(results)

asyncio.run(main())

Expected Output:

['Result from 1', 'Result from 2', 'Result from 3']

There are two critical characteristics of gather to note here:

  1. Concurrency: Although my_coroutine(1, 1.5) is the longest, the total execution time will be ~1.5 seconds, not 3.0 seconds (1.5 + 0.5 + 1.0).
  2. Order of Results: The results list is ['Result from 1', 'Result from 2', 'Result from 3']. gather always returns the results in the same order that the awaitables were passed to it, regardless of their completion order. This is incredibly convenient for processing results, as you don't need to manually map results back to their original calls.

This article provides a concise reference on the gather function's signature and behavior.

Python asyncio.gather() - Run Multiple Asynchronous Operations

This tutorial from PythonTutorial.net serves as a great quick reference.

Read the introduction and the first example under "Using asyncio.gather() to run multiple asynchronous operations". This will reinforce the basic syntax and the concept of ordered results.

When you pass coroutine objects directly to gather, it automatically schedules them as tasks for you. This is the most common and convenient way to use it. However, if you need more control (e.g., to cancel a specific task before gather completes), you can create tasks manually with asyncio.create_task and pass those Task objects to gather. The end result of awaiting gather is the same.


Building Resilient Systems: Handling Exceptions

In a distributed system, failure is inevitable. A network call might time out, a service might be down, or an API might return an error. How gather behaves in the face of exceptions is critical for building robust applications.

By default, gather has a "fail-fast" behavior. If any of the awaitables passed to it raises an exception, gather immediately cancels the waiting and propagates that first exception. You lose the results of all other awaitables, even those that completed successfully.

Consider this code:

import asyncio

async def success_coro():
    await asyncio.sleep(1)
    return "Success"

async def fail_coro():
    await asyncio.sleep(0.5)
    raise ValueError("Something went wrong")

async def main():
    try:
        results = await asyncio.gather(
            success_coro(),
            fail_coro()
        )
        print(results)
    except ValueError as e:
        print(f"Caught an exception: {e}")



        # The result from success_coro() is lost!

asyncio.run(main())

Output:

Caught an exception: Something went wrong

This is often not what you want in a microservices context. If you're fetching data for a user dashboard from three services and one fails, you'd ideally want to show the data from the two successful services and an error message for the failed one, rather than showing a full-page error.

The return_exceptions=True Pattern

To handle this, gather provides a crucial parameter: return_exceptions. When set to True, gather will not raise an exception. Instead, it will wait for all awaitables to finish and include any exception objects in the results list, right alongside the successful return values.

Python asyncio.gather() - Exception Handling

Let's return to the PythonTutorial.net article to see how to handle exceptions correctly.

Read the sections on "Using asyncio.gather() to run multiple asynchronous operations with exceptions" and "Using asyncio.gather() to return an exception in the result". The contrast between the default behavior and using return_exceptions=True is the key takeaway.

This pattern is fundamental for building resilient systems. It allows you to process partial successes and failures gracefully.

The video from Corey Schafer also provides an excellent discussion on gather versus a newer alternative, TaskGroup, highlighting their differences in error handling. While TaskGroup enforces "all or nothing" semantics (which we covered in the last lesson), gather with return_exceptions=True is the go-to tool for "best effort" scenarios.

Python Tutorial: AsyncIO - Complete Guide to Asynchronous Programming with Animations

This segment from Corey Schafer's "AsyncIO - Complete Guide" explains how to run and await multiple tasks.

Watch the part discussing gather and TaskGroup. Focus on the explanation of how they handle errors differently. This will cement your understanding of when to use gather(return_exceptions=True).


Practical Exercise: The Aggregating API Gateway

Let's put this into practice. You are building an API gateway that serves a user's dashboard. To do this, it needs to concurrently fetch three pieces of information from different downstream microservices:

  1. The user's profile.
  2. The user's recent orders.
  3. The user's notification settings.

One of these services might fail. Your gateway should still return as much data as possible.

Your Task:

  1. Write three coroutines: fetch_profile(user_id), fetch_orders(user_id), and fetch_notifications(user_id).
  2. Each coroutine should simulate a network call with asyncio.sleep() using different delays.
  3. Make one of the coroutines, for instance fetch_orders, raise a TimeoutError("Order service timed out").
  4. In your main function, use asyncio.gather with return_exceptions=True to call these three coroutines for a given user_id.
  5. Process the list of results from gather. Construct a final JSON-like dictionary representing the aggregated response.
    • If a result is a success, add it to the dictionary.
    • If a result is an exception, add an error message for that key.

Example final output dictionary:

{
    "profile": {"name": "Test User", "email": "test@example.com"},
    "orders": {"error": "Order service timed out"},
    "notifications": {"email_enabled": True, "sms_enabled": False}
}
Click here to see a possible solution
import asyncio
import time

async def fetch_profile(user_id: int) -> dict:
    """Fetches user profile data."""
    print(f"Fetching profile for user {user_id}...")
    await asyncio.sleep(0.8)
    return {"name": "Test User", "email": "test@example.com"}

async def fetch_orders(user_id: int) -> list:
    """Fetches user orders. This one will fail."""
    print(f"Fetching orders for user {user_id}...")
    await asyncio.sleep(1.2)
    raise TimeoutError("Order service timed out")

async def fetch_notifications(user_id: int) -> dict:
    """Fetches user notification settings."""
    print(f"Fetching notifications for user {user_id}...")
    await asyncio.sleep(0.5)
    return {"email_enabled": True, "sms_enabled": False}

async def get_user_dashboard_data(user_id: int) -> dict:
    """Aggregates data for the user dashboard using asyncio.gather."""
    
    start_time = time.perf_counter()

    profile_coro = fetch_profile(user_id)
    orders_coro = fetch_orders(user_id)
    notifications_coro = fetch_notifications(user_id)




    # Run all coroutines concurrently and collect results/exceptions
    results = await asyncio.gather(
        profile_coro,
        orders_coro,
        notifications_coro,
        return_exceptions=True
    )
    
    end_time = time.perf_counter()
    print(f"\nData aggregation took {end_time - start_time:.2f} seconds.")




    # Process results to build the final response
    dashboard_data = {}
    



    # Profile data
    if isinstance(results[0], Exception):
        dashboard_data["profile"] = {"error": str(results[0])}
    else:
        dashboard_data["profile"] = results[0]




    # Orders data
    if isinstance(results[1], Exception):
        dashboard_data["orders"] = {"error": str(results[1])}
    else:
        dashboard_data["orders"] = results[1]




    # Notifications data
    if isinstance(results[2], Exception):
        dashboard_data["notifications"] = {"error": str(results[2])}
    else:
        dashboard_data["notifications"] = results[2]

    return dashboard_data

async def main():
    user_id = 123
    print(f"--- Building dashboard for user {user_id} ---")
    dashboard = await get_user_dashboard_data(user_id)
    print("\n--- Aggregated Dashboard Response ---")
    import json
    print(json.dumps(dashboard, indent=2))


if __name__ == "__main__":
    asyncio.run(main())

Conclusion

You've now taken a deep dive into asyncio.gather, one of the most practical and frequently used tools in the asyncio toolkit. You've seen how it enables massive performance gains for I/O-bound work and, most importantly, how to use it to build resilient systems that can gracefully handle the partial failures common in microservice architectures.

Key Takeaways:

  • asyncio.gather is your primary tool for running a collection of independent awaitables concurrently.
  • It significantly reduces total execution time for I/O-bound operations by running them in parallel, limited only by the longest-running operation.
  • It conveniently returns results in the same order as the inputs, simplifying data processing.
  • Using return_exceptions=True is the standard pattern for building robust services that can tolerate and report on partial failures, rather than failing completely.

Preview of the Next Lesson:

So far, our entire discussion has centered on I/O-bound tasks—operations like network requests or database queries where the program spends most of its time waiting. But what about tasks that are CPU-bound, like complex calculations, data compression, or image processing? Running these in an async function would block the entire event loop, defeating the purpose of asyncio.

In the next lesson, we will distinguish between I/O-bound and CPU-bound tasks and explore the correct concurrency models for each, introducing Python's multiprocessing as the solution for true parallelism with CPU-heavy workloads.