Run Multiple Tasks Concurrently in Python

Learn how to run many async tasks concurrently in Python using gather, TaskGroup, wait, as_completed, semaphores, and concurrency limiting patterns.

8 min read

Running one or two async tasks concurrently is straightforward. Running hundreds or thousands requires patterns that manage concurrency, collect results, handle failures, and avoid overwhelming the event loop or the external services you are calling. Python's asyncio module gives you four primary tools for running multiple tasks: gather, TaskGroup, wait, and as_completed.

Each solves a different coordination problem, and choosing the right one determines whether your code is clean and correct or tangled with manual bookkeeping. If you have read about creating and running async tasks, you understand the task lifecycle. This article covers the higher-level patterns that coordinate many tasks at once.

The key difference between these tools is what they optimize for. gather optimizes for simplicity when you want all results in a known order. TaskGroup optimizes for safety by guaranteeing cleanup.

wait optimizes for flexibility when completion order or partial results matter. as_completed optimizes for responsiveness when you want to process results as they arrive.

asyncio.gather: ordered results from concurrent coroutines

gather runs multiple awaitables concurrently and returns a list of results in the same order as the inputs. It is the simplest tool and the right default when you have a known set of coroutines and need all their results.

pythonpython
import asyncio
import time
 
async def fetch(url, delay):
    await asyncio.sleep(delay)
    return f"Content of {url}"
 
async def main():
    start = time.perf_counter()
 
    results = await asyncio.gather(
        fetch("https://a.example.com", 0.2),
        fetch("https://b.example.com", 0.1),
        fetch("https://c.example.com", 0.3),
    )
 
    for result in results:
        print(result)
    print(f"Total: {time.perf_counter() - start:.2f}s")
 
asyncio.run(main())

The slowest fetch takes 0.3 seconds, and since all three run concurrently, the total matches that single slowest delay instead of the 0.6 seconds a sequential run of all three would take:

texttext
Content of https://a.example.com
Content of https://b.example.com
Content of https://c.example.com
Total: 0.30s

All three fetches run concurrently. The total time equals the slowest individual operation, not the sum. Results are returned in the same order as the arguments regardless of which coroutine finishes first.

When one coroutine raises an exception, gather raises it immediately and attempts to cancel the remaining coroutines. To collect exceptions instead, use return_exceptions=True:

pythonpython
async def main():
    results = await asyncio.gather(
        safe_fetch("A"),
        failing_fetch("B"),
        safe_fetch("C"),
        return_exceptions=True,
    )
 
    for i, result in enumerate(results):
        if isinstance(result, Exception):
            print(f"Task {i} failed: {result}")
        else:
            print(f"Task {i} succeeded: {result}")

This pattern lets all coroutines complete even when some fail. It is essential for batch processing where one failure should not block the rest.

TaskGroup: structured concurrent execution

TaskGroup (Python 3.11+) ensures that all tasks in the group complete before the block exits. If any task fails, all remaining tasks are cancelled, and an ExceptionGroup is raised containing every failure.

pythonpython
import asyncio
 
async def worker(name, delay, should_fail=False):
    await asyncio.sleep(delay)
    if should_fail:
        raise ValueError(f"{name} failed")
    return f"{name} done"
 
async def main():
    async with asyncio.TaskGroup() as tg:
        task_a = tg.create_task(worker("A", 0.2))
        task_b = tg.create_task(worker("B", 0.1))
        task_c = tg.create_task(worker("C", 0.3))
 
    print(task_a.result())
    print(task_b.result())
    print(task_c.result())
 
asyncio.run(main())

TaskGroup is preferred over raw create_task() for most concurrent work because it prevents orphaned tasks. A TaskGroup created inside an async with block guarantees that when the block exits, every task in the group has completed. You never need to manually track which tasks you created or ensure they are all awaited.

When combining TaskGroup with a dynamic number of tasks:

pythonpython
async def main():
    urls = ["a.example.com", "b.example.com", "c.example.com"]
 
    async with asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(fetch(url, 0.1)) for url in urls]
 
    results = [t.result() for t in tasks]
    print(results)

asyncio.wait: flexible completion control

asyncio.wait() gives you finer control over when to stop waiting. It returns two sets: done tasks and pending tasks. The return_when parameter controls the stop condition.

pythonpython
import asyncio
 
async def fetch(source, delay):
    await asyncio.sleep(delay)
    return f"Data from {source}"
 
async def main():
    tasks = [
        asyncio.create_task(fetch("API", 0.3)),
        asyncio.create_task(fetch("Cache", 0.1)),
        asyncio.create_task(fetch("DB", 0.2)),
    ]
 
    done, pending = await asyncio.wait(
        tasks, return_when=asyncio.FIRST_COMPLETED
    )
 
    print(f"First done: {done.pop().result()}")
 
    for task in pending:
        task.cancel()
 
asyncio.run(main())

return_when accepts three values:

  • ALL_COMPLETED (default): wait until every task finishes.
  • FIRST_COMPLETED: return as soon as any task finishes.
  • FIRST_EXCEPTION: return as soon as any task raises, or all complete.

wait() is the right tool when you need to race tasks, implement timeouts with partial results, or handle completion dynamically based on which tasks finish first.

asyncio.as_completed: process results as they arrive

as_completed() returns an iterator that yields tasks as they finish, in completion order. This lets you start processing early results while slower tasks are still running.

pythonpython
import asyncio
import random
 
async def process_item(item_id):
    delay = random.uniform(0.1, 0.5)
    await asyncio.sleep(delay)
    return f"Processed item {item_id}"
 
async def main():
    coros = [process_item(i) for i in range(10)]
    tasks = [asyncio.create_task(c) for c in coros]
 
    for completed in asyncio.as_completed(tasks):
        result = await completed
        print(result)
 
asyncio.run(main())

as_completed() is ideal for streaming processing where you want to act on results immediately. A web scraper that saves each page as it downloads, a pipeline that processes data chunks as they arrive, or a monitoring system that reports results in real time all benefit from as_completed.

The order of results depends on task completion time, which is unpredictable. If you need to know which input produced which result, store a mapping from task to input:

pythonpython
async def main():
    urls = ["a.com", "b.com", "c.com"]
    tasks = {asyncio.create_task(fetch(url, random.uniform(0.1, 0.5))): url
             for url in urls}
 
    for completed in asyncio.as_completed(tasks):
        url = tasks[completed]
        result = await completed
        print(f"{url}: {result}")

Limiting concurrency with Semaphore

When you have thousands of tasks but the resource you are calling can only handle a limited number of concurrent requests, use asyncio.Semaphore to cap concurrency.

pythonpython
import asyncio
 
async def fetch_with_limit(sem, url):
    async with sem:
        print(f"Fetching {url}")
        await asyncio.sleep(0.2)
        return f"Content of {url}"

Wrapping the whole 20-URL job in a TaskGroup still guarantees cleanup, while the semaphore inside fetch_with_limit is what actually caps how many of those 20 tasks touch the network at once:

pythonpython
async def main():
    urls = [f"https://example.com/page/{i}" for i in range(20)]
    sem = asyncio.Semaphore(5)
 
    async with asyncio.TaskGroup() as tg:
        tasks = [
            tg.create_task(fetch_with_limit(sem, url))
            for url in urls
        ]
 
    for task in tasks:
        print(task.result())
 
asyncio.run(main())

At most 5 fetches run concurrently. When a sixth coroutine reaches async with sem, it blocks until one of the first five exits its async with sem block. The semaphore acts as a gate, ensuring the external service never receives more than 5 concurrent requests.

A semaphore initialized to N allows N concurrent holders. You can think of it as a counter that starts at N, decrements on entry, and increments on exit. When the counter is zero, the next entrant blocks.

Combining patterns: semaphore with as_completed

For large-scale batch processing, combine a semaphore with as_completed:

pythonpython
import asyncio
 
async def process(sem, item_id):
    async with sem:
        await asyncio.sleep(0.1)
    return f"Result {item_id}"

Creating all 100 tasks up front does not mean all 100 run at once. Each task still has to pass through the semaphore in process, so only 10 are ever doing real work while the rest wait their turn:

pythonpython
async def main():
    items = list(range(100))
    sem = asyncio.Semaphore(10)
 
    tasks = [
        asyncio.create_task(process(sem, item_id))
        for item_id in items
    ]
 
    results = []
    for completed in asyncio.as_completed(tasks):
        result = await completed
        results.append(result)
        print(f"Completed: {len(results)}/100")
 
    print("All done.")
 
asyncio.run(main())

This pattern limits concurrency to 10 while processing results as they complete. It scales from 100 items to 100,000 without modification and without overwhelming resources.

Choosing the right tool

PatternUse when
gather()You have a fixed set of coroutines and need ordered results
TaskGroupYou need guaranteed cleanup and structured error handling
wait(FIRST_COMPLETED)You need the fastest result and can discard the rest
wait(ALL_COMPLETED)You need flexible completion handling with timeouts
as_completed()You want to process results in completion order
SemaphoreYou need to cap concurrent access to a resource

For most batch processing, start with gather() and switch to TaskGroup when you need the safety guarantee. Add a semaphore when you need to limit concurrency. Use as_completed() when you want to stream results rather than wait for all of them.

The next articles in this section cover async iterators and generators for streaming data through async pipelines, and async context managers for managing async resources with deterministic setup and teardown.

Rune AI

Rune AI

Key Insights

  • asyncio.gather() runs coroutines concurrently and returns results in input order; use return_exceptions=True to collect errors.
  • TaskGroup guarantees all tasks complete as a unit and cancels siblings on failure.
  • asyncio.wait() with FIRST_COMPLETED races tasks; with ALL_COMPLETED it waits for everything.
  • asyncio.as_completed() yields results in completion order for streaming processing.
  • asyncio.Semaphore limits concurrent task count to prevent overwhelming resources.
RunePowered by Rune AI

Frequently Asked Questions

What is the difference between asyncio.gather and asyncio.wait?

gather() runs coroutines concurrently and returns results in the same order as inputs. It raises the first exception immediately unless return_exceptions=True is set. wait() returns two sets of tasks (done and pending) and lets you control when to stop (FIRST_COMPLETED, FIRST_EXCEPTION, or ALL_COMPLETED). Use gather() when you need ordered results from a known set of coroutines. Use wait() when you need to race tasks against each other or handle completion flexibly.

How do I limit the number of concurrent async tasks?

Use asyncio.Semaphore to cap concurrent access. Create the semaphore with max concurrency, then use async with semaphore: inside each worker coroutine. The semaphore blocks the coroutine at the async with statement when the limit is reached, and resumes it when another coroutine exits its block. This pattern prevents overwhelming event loops or external APIs without a thread pool.

Can I use as_completed with hundreds of tasks without running out of memory?

Yes. as_completed() yields futures as they finish regardless of how many were submitted. However, all submitted tasks exist simultaneously in the event loop, so the limit is event loop memory and scheduling overhead, not as_completed itself. For millions of tasks, batch them or use a producer-consumer pattern with a bounded queue instead of submitting all at once.

Conclusion

Running multiple tasks concurrently is where asyncio delivers its value. gather for ordered results, TaskGroup for structured concurrency, wait for racing and flexible completion, as_completed for processing results as they arrive, and Semaphore for concurrency limits. These five patterns cover nearly every real-world async batch-processing scenario. Choose the simplest one that fits your use case and only add complexity when the simpler pattern is insufficient.