Skip to Content
Running Many Requests at Once With asyncio.gather
Advanced
0 likes
Tutorials

Running Many Requests at Once With asyncio.gather

asyncio.gather runs many waiting tasks at once in one thread and returns results in order. Avoid await in a loop and blocking calls, keep failures with return_exceptions=True, and limit concurrency with a Semaphore.

asyncio.gather runs many waiting tasks at the same time in one thread and gives you all the results in order. If your program already uses async, or you need hundreds of requests at once, it's the right tool. If you have a normal script and a few dozen downloads, ThreadPoolExecutor is simpler, and I'd use that instead.

Skip ahead: If you only need the pattern, go to "A real version with httpx". Read "Limit how many run at once" before you send 500 requests to anyone.

The idea in 20 lines

To keep the timing simple, this first example fakes a request with asyncio.sleep, which waits without blocking anything else:

import asyncio
import time


async def fetch(name, seconds):
    await asyncio.sleep(seconds)
    return f"{name} done after {seconds}s"


async def main():
    start = time.perf_counter()
    results = await asyncio.gather(
        fetch("orders", 1.0),
        fetch("stock", 0.5),
        fetch("prices", 0.8),
    )
    for line in results:
        print(line)
    print(f"total: {time.perf_counter() - start:.1f}s")


asyncio.run(main())
orders done after 1.0s
stock done after 0.5s
prices done after 0.8s
total: 1.0s

Three waits of 1.0, 0.5 and 0.8 seconds took 1.0 second in total, the length of the slowest one. And the results came back in the order you passed them, not the order they finished. stock finished first, but it's second in the list, because it was second in the call.

Three words do all the work:

  • **async def** makes a coroutine function. Calling it doesn't run it. It creates a task-to-be.
  • **await** means "wait for this, and let other tasks run meanwhile".
  • **asyncio.run(main())** starts the event loop that switches between tasks, runs main, and closes everything at the end.

The mistake that makes it slow again

import asyncio
import time


async def fetch(name, seconds):
    await asyncio.sleep(seconds)
    return name


async def main():
    start = time.perf_counter()
    for name, seconds in [("orders", 1.0), ("stock", 0.5), ("prices", 0.8)]:
        await fetch(name, seconds)
    print(f"one by one: {time.perf_counter() - start:.1f}s")


asyncio.run(main())
one by one: 2.3s

await inside a loop waits for each one to finish before starting the next. That's sequential code with extra keywords. To run things together, create all of them first and gather them.

⚠ Warning: One blocking call ruins everything. time.sleep(), requests.get() or urllib.request.urlopen() inside an async function blocks the whole event loop, and every other task stops until it returns. Inside async code, use async libraries (asyncio.sleep, httpx.AsyncClient, aiohttp). If you have to call a blocking function, wrap it: await asyncio.to_thread(blocking_function, arg).

A real version with httpx

The standard library has no async HTTP client, so real requests need a package. This uses httpx (python -m pip install httpx). To keep the timings repeatable, it talks to a small test server on your own computer that waits half a second per request. Save it and run it in a separate terminal:

import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer


class SlowHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        time.sleep(0.5)
        status = 404 if "missing" in self.path else 200
        body = f'{{"path": "{self.path}"}}'.encode()
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *args):
        pass


ThreadingHTTPServer.request_queue_size = 100  # let many connections wait at once
ThreadingHTTPServer(("127.0.0.1", 8766), SlowHandler).serve_forever()
import asyncio
import time

import httpx

URLS = [f"http://127.0.0.1:8766/orders/{n}" for n in range(1, 21)]


async def fetch(client, url):
    response = await client.get(url, timeout=10)
    response.raise_for_status()
    return response.json()


async def main():
    start = time.perf_counter()
    async with httpx.AsyncClient() as client:
        results = await asyncio.gather(*(fetch(client, url) for url in URLS))
    print(len(results), "responses, first:", results[0])
    print(f"total: {time.perf_counter() - start:.1f}s")


asyncio.run(main())
20 responses, first: {'path': '/orders/1'}
total: 0.6s

Twenty requests of half a second each, in about half a second. Two details:

  1. **One AsyncClient for all requests**, created with async with so it's closed properly. Creating a new client per request throws away connection reuse.
  2. **The * in gather(*(...))** unpacks the list of coroutines into separate arguments. gather takes them one by one, not as a list.

When one fails

By default, the first exception is raised out of gather, and you lose every result:

import asyncio

import httpx

URLS = ["http://127.0.0.1:8766/orders/1", "http://127.0.0.1:8766/missing", "http://127.0.0.1:8766/orders/3"]


async def fetch(client, url):
    response = await client.get(url, timeout=10)
    response.raise_for_status()
    return response.json()


async def main():
    async with httpx.AsyncClient() as client:
        results = await asyncio.gather(*(fetch(client, url) for url in URLS), return_exceptions=True)
    for url, result in zip(URLS, results):
        if isinstance(result, Exception):
            print("FAILED", url, "-", type(result).__name__)
        else:
            print("OK", url)


asyncio.run(main())
OK http://127.0.0.1:8766/orders/1
FAILED http://127.0.0.1:8766/missing - HTTPStatusError
OK http://127.0.0.1:8766/orders/3

return_exceptions=True puts the exception in the results list, in that request's position, instead of raising it. Now you can see which one failed and keep the rest. Always check each result with isinstance(result, Exception). Otherwise you'll treat an error object as data.

Limit how many run at once

gather starts everything at once. With 20 URLs that's fine. With 2,000, you open 2,000 connections, and the server will probably slow you down, block you, or fall over. A semaphore lets only a fixed number through at a time:

import asyncio
import time

import httpx

URLS = [f"http://127.0.0.1:8766/orders/{n}" for n in range(1, 21)]


async def fetch(client, limit, url):
    async with limit:
        response = await client.get(url, timeout=10)
        response.raise_for_status()
        return response.json()


async def main():
    limit = asyncio.Semaphore(5)
    start = time.perf_counter()
    async with httpx.AsyncClient() as client:
        results = await asyncio.gather(*(fetch(client, limit, url) for url in URLS))
    print(len(results), "responses")
    print(f"total with 5 at a time: {time.perf_counter() - start:.1f}s")


asyncio.run(main())
20 responses
total with 5 at a time: 2.1s

Four rounds of five, about two seconds. Slower than all at once, and much kinder to the server. For anything you don't own, start with a small limit and check the API's documented rate limits.

gather or threads?

  • A normal script, tens of requests: ThreadPoolExecutor. No new libraries, no async everywhere.
  • Already an async app (FastAPI, a Discord bot, an async web scraper): gather. Threads inside an event loop are awkward.
  • Hundreds or thousands of connections at once: asyncio. Each waiting task costs far less than a thread.

Common mistakes

  • **await in a loop.** Sequential again. Gather them.
  • **A blocking call inside async def.** Everything stops. Use the async library or asyncio.to_thread.
  • No limit. Use a Semaphore for anything more than a handful.
  • **No return_exceptions=True** when some failures are expected. One error loses all results.

What happens if you get it wrong

The common failure isn't a crash. It's async code that runs exactly as slowly as the old version, because of one blocking call or an await inside a loop. You added the complexity and got none of the speed.

If you added a print with the time at the start and end of each request, would they overlap, or line up one after another?

About

async, await and asyncio.run, the await-in-a-loop mistake, real requests with httpx, return_exceptions, Semaphore limits, and gather vs threads. After this, your async code actually runs requests at the same time.

Responsible
John G
Last Update
09/11/2026
Reading Time
7 min
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.