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, runsmain, 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:
- **One
AsyncClientfor all requests**, created withasync withso it's closed properly. Creating a new client per request throws away connection reuse. - **The
*ingather(*(...))** unpacks the list of coroutines into separate arguments.gathertakes 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, noasynceverywhere. - 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
- **
awaitin a loop.** Sequential again. Gather them. - **A blocking call inside
async def.** Everything stops. Use the async library orasyncio.to_thread. - No limit. Use a
Semaphorefor 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
Reviews
Sign in to write a reviewComments (0)
Sign in to commentNo comments yet.