Blog post image for Python Async HTTP Requests with aiohttp: Fetch Multiple URLs Concurrently - A Python snippet demonstrating concurrent HTTP requests using aiohttp and asyncio. Covers creating sessions, fetching multiple URLs in parallel, handling errors gracefully, and controlling concurrency with semaphores.
Codesnippets

Python Async HTTP Requests with aiohttp: Fetch Multiple URLs Concurrently

Python Async HTTP Requests with aiohttp: Fetch Multiple URLs Concurrently

Published: Updated: 05 Mins read07 Mins listen

Quick Tip

Reuse one aiohttp session, fan your requests out with asyncio.gather, and cap them with a semaphore to fetch hundreds of URLs in the time one loop would take.

The Problem

The Problem

A script that fetches a list of URLs with requests in a plain for loop spends almost all of its time doing nothing. Each call opens a connection, sends the request, and then blocks until the response comes back before the next one even starts. The network is slow and your CPU is fast, so the program just sits and waits, one round trip at a time.

Why sequential requests waste time

HTTP is I/O bound. The bottleneck isn’t your code, it’s the latency of each request. If a single call takes 200ms and you have 200 URLs, a sequential loop takes 40 seconds, and 39 of those seconds are pure waiting. Nothing about that work needs to happen in order, yet the loop forces it to.

The real-world impact

That waiting shows up as slow batch jobs, sluggish data-collection scripts, and API clients that time out because they can’t keep up. Scale the list up and it gets worse linearly: a report that pulls from a few hundred endpoints turns a quick task into a coffee break, and a service that fans out to several upstreams makes every user wait for the slowest chain of calls.

The Solution

The Fix

Use aiohttp with asyncio so the requests overlap. While one call waits on the network, the event loop starts the next one. Reuse a single ClientSession so connections get pooled, launch everything with asyncio.gather, and put a Semaphore in front to keep the number of in-flight requests sane.

TL;DR

  • Create one aiohttp.ClientSession and reuse it for every request.
  • Fire all the fetches concurrently with asyncio.gather instead of a blocking loop.
  • Cap concurrency with asyncio.Semaphore so you don’t overwhelm the server or get rate-limited.

Script Implementation

Setup and imports

Start with the async pieces you need: asyncio for the event loop and aiohttp for the HTTP calls. A small result type keeps the return values easy to read.

fetch_urls.py
#!/usr/bin/env python3
"""fetch_urls.py - fetch many URLs concurrently with aiohttp."""
import asyncio
import time
from dataclasses import dataclass
import aiohttp
# Cap how many requests run at once. Tune to the server and your rate limits.
MAX_CONCURRENCY = 10
REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=15)
@dataclass
class Result:
url: str
status: int | None # HTTP status, or None if the request never completed
body: str | None # response text on success
error: str | None # error message on failure

Fetching one URL safely

Each fetch takes the shared session and the semaphore. The semaphore blocks once MAX_CONCURRENCY requests are already in flight, so the rest queue up instead of stampeding the server. The try/except keeps a single bad URL from crashing the whole batch.

async def fetch_one(
session: aiohttp.ClientSession,
sem: asyncio.Semaphore,
url: str,
) -> Result:
# The semaphore is the throttle: only MAX_CONCURRENCY of these run at once.
async with sem:
try:
async with session.get(url) as response:
# Read the body while the connection is still open.
text = await response.text()
return Result(url, response.status, text, None)
except aiohttp.ClientError as exc:
# Network, DNS, or connection errors land here.
return Result(url, None, None, f"client error: {exc}")
except asyncio.TimeoutError:
return Result(url, None, None, "timed out")

Fetching the whole batch

Create one session and one semaphore, then build a task per URL and hand them all to asyncio.gather. Because every fetch_one already catches its own errors, gather returns a clean list of Result objects with no partial failures to unpack.

async def fetch_all(urls: list[str]) -> list[Result]:
sem = asyncio.Semaphore(MAX_CONCURRENCY)
# One session for the whole run so connections get pooled and reused.
async with aiohttp.ClientSession(timeout=REQUEST_TIMEOUT) as session:
tasks = [fetch_one(session, sem, url) for url in urls]
# gather runs them concurrently and preserves input order.
return await asyncio.gather(*tasks)

Entry point

Wrap the run in asyncio.run, then print a short summary. Timing the run makes the win obvious the first time you compare it to a sequential loop.

def main() -> None:
urls = [f"https://httpbin.org/delay/1?id={i}" for i in range(20)]
start = time.perf_counter()
results = asyncio.run(fetch_all(urls))
elapsed = time.perf_counter() - start
ok = sum(1 for r in results if r.status == 200)
failed = [r for r in results if r.error]
print(f"fetched {ok}/{len(results)} in {elapsed:.2f}s")
for r in failed:
print(f" failed: {r.url} ({r.error})")
if __name__ == "__main__":
main()

Usage and Benefits

Why This Helps

One session, one semaphore, and one gather call turn a linear wait into an overlapping one. The requests still take the same time individually, but they no longer take that time one after another, so a batch that scaled with the number of URLs now scales with your concurrency limit instead.

Real invocations

Terminal window
# Install the dependency first.
pip install aiohttp
# Run the script. Twenty 1-second requests finish in about 2-3 seconds,
# not 20, because up to MAX_CONCURRENCY of them run at the same time.
python fetch_urls.py

Tuning concurrency for the target

There’s no universal number for MAX_CONCURRENCY. A generous internal API might handle 50 in-flight requests; a rate-limited public one might block you above 5. Start low, watch for 429 responses, and raise it until you hit the server’s comfort zone.

# Lower the limit for a strict public API to avoid 429s.
MAX_CONCURRENCY = 5
# Raise it for a fast internal service that can take the load.
MAX_CONCURRENCY = 50

Comparison

How the concurrent approach stacks up against the other common ways to fetch a list of URLs.

ApproachConcurrentConnection reuseConcurrency controlBest for
aiohttp + asyncio.gatherYesYes, pooledYes, semaphoreLarge async I/O-bound fetches
requests in a for loopNoPer session onlyN/AA handful of simple calls
requests + ThreadPoolYesYesPool sizeMixing with blocking code
httpx.AsyncClientYesYes, pooledYes, limits/semaphoreAsync with a requests-like API

The threaded pool is a fine middle ground when the rest of your code is synchronous, but an event loop scales to far more concurrent connections without spinning up a thread per request.

Frequently Asked Questions

A ClientSession owns a connection pool. Reusing it lets aiohttp keep TCP and TLS connections alive across requests, which skips the handshake cost on every call. Creating a fresh session per request throws that away and leaks connections. Open one session for the batch, pass it to every fetch, and close it with async with when you’re done.

Without a limit, asyncio.gather starts every request at once. A thousand URLs means a thousand simultaneous connections, which can exhaust file descriptors on your side and trip rate limits or overload the server on the other. The semaphore caps how many run concurrently: each async with sem acquires a slot and releases it when the request finishes, so the rest wait their turn.

Catch the errors inside each task, which is what fetch_one does with its try/except. Because every coroutine returns a Result instead of raising, asyncio.gather never sees an exception and returns a full list. If you’d rather let exceptions propagate, use asyncio.gather(*tasks, return_exceptions=True) and inspect the returned values, some of which will be exception objects.

Yes. gather returns results in the same order you passed the awaitables, regardless of which requests finish first. So results[i] always corresponds to urls[i], even though the fetches complete out of order. If you don’t care about order and want to process results as they arrive, look at asyncio.as_completed instead.

Reach for a thread pool with requests when the surrounding code is synchronous and you don’t want to convert it to async just for a few calls. Reach for httpx.AsyncClient when you want an async client with an API closer to requests, or you need HTTP/2. aiohttp shines when you’re already in an async application and need to fan out to many endpoints at high concurrency.

Pass an aiohttp.ClientTimeout to the session, as the snippet does with REQUEST_TIMEOUT. A total timeout bounds the whole request, including connect and read. When it’s exceeded, aiohttp raises asyncio.TimeoutError, which the except in fetch_one turns into a clean failed Result instead of a hung coroutine.

References

You might also enjoy

Check out some of our other posts on similar topics

Optimizing your python code with __slots__?

Optimizing your python code with __slots__?

Memory Optimization with slots Understanding the Problem Dev Tip: Optimizing Data Models in Big Data Workflows with slots In big data and MLOps workflows, you often work with

List S3 Buckets

List S3 Buckets

Overview Multi-Profile S3 Management Multi-Profile S3 Safari! Ever juggled multiple AWS accounts and needed a quick S3 bucket inventory across all of them? This Python script is your guid

Redis Caching Patterns: Cache-Aside, Write-Through & Cache Invalidation

Redis Caching Patterns: Cache-Aside, Write-Through & Cache Invalidation

Need to scale your backend without throwing money at servers? Redis caching patterns are your answer. Most databases can handle hundreds of queries per second, but thousands? Your app slows to a

AWS EC2 Instance Management with Boto3: Start, Stop, and Query Instances

AWS EC2 Instance Management with Boto3: Start, Stop, and Query Instances

If you've ever spent 20 minutes clicking through the AWS Console just to stop a handful of dev instances, you already know the pain. It's tedious, it doesn't scale, and one wrong click can ruin your a

PostgreSQL Query Optimization: Indexes, EXPLAIN ANALYZE & Execution Plans

PostgreSQL Query Optimization: Indexes, EXPLAIN ANALYZE & Execution Plans

Need to optimize slow PostgreSQL queries? Here's how with EXPLAIN ANALYZE and strategic indexing. Slow database queries kill application performance. But most developers don't know where the actu

Bash Retry Function: Automatically Retry Failing Commands with Exponential Backoff

Bash Retry Function: Automatically Retry Failing Commands with Exponential Backoff

Quick Tip Wrap any flaky command in one reusable retry function and stop re-running red pipelines by hand. The Problem The Problem Some commands fail for reasons that have nothing to

6 related posts