ZetCode

Python concurrent HTTP requests

last modified October 30, 2026 Python 3.15

In this article we show how to send concurrent HTTP requests in Python.

The Hypertext Transfer Protocol (HTTP) is an application protocol for distributed, collaborative, hypermedia information systems. Clients and servers communicate by exchanging messages: the client sends a request and the server answers with a response.

Note: the word concurrent means that we handle multiple tasks within a specific time frame. This does not necessarily imply that the tasks run at the same moment. While one request waits for the network, the program can start or finish other requests.

Concurrent requests

Requests can be handled sequentially or concurrently. Sequential requests are sent one by one; each request waits for the previous one to finish. Most of that time is spent waiting for the network, so the total time is roughly the sum of all response times. With concurrent requests, the waiting overlaps and the total time approaches the time of the slowest request.

There are two basic ways to send concurrent requests in Python: multiple threads or asynchronous programming. In the multi-threaded approach, each request runs in its own thread, typically from a ThreadPoolExecutor. In asynchronous programming, there is one thread and an event loop that switches between coroutines whenever one of them waits for I/O. Async programming requires libraries built for it, such as httpx or aiohttp.

The examples use the https://httpbin.org/delay/n endpoint, which responds after n seconds. We request five URLs with delays of 2, 1, 3, 1, and 2 seconds, nine seconds in total. Network latency adds to these times, so measured times vary from run to run.

We install the three HTTP libraries used in the examples.

$ pip install requests httpx aiohttp

Sequential requests

In the first example, we send the requests one after another. To measure the elapsed time, we use the perf_counter function.

mul_sync.py
import time
import requests

urls = [f'https://httpbin.org/delay/{d}' for d in (2, 1, 3, 1, 2)]

start = time.perf_counter()

with requests.Session() as session:
    for url in urls:
        resp = session.get(url, timeout=10)
        print(resp.status_code, url)

elapsed = time.perf_counter() - start
print(f'Total time elapsed: {elapsed:.2f} seconds')

We use the requests library.

with requests.Session() as session:
    for url in urls:
        resp = session.get(url, timeout=10)

A Session reuses the TCP connection to the same host, which saves the connection setup for every request after the first. The timeout parameter prevents a request from hanging forever.

The total time is the sum of the delays plus the network overhead of each request.

$ python mul_sync.py
200 https://httpbin.org/delay/2
200 https://httpbin.org/delay/1
200 https://httpbin.org/delay/3
200 https://httpbin.org/delay/1
200 https://httpbin.org/delay/2
Total time elapsed: 11.05 seconds

Threads with executor.map

A ThreadPoolExecutor from the concurrent.futures module runs functions in a pool of threads. Its map method works like the built-in map, but calls the function concurrently.

threaded_map.py
import time
from concurrent.futures import ThreadPoolExecutor
import requests

urls = [f'https://httpbin.org/delay/{d}' for d in (2, 1, 3, 1, 2)]

def get_status(url):
    resp = requests.get(url, timeout=10)
    return resp.status_code

start = time.perf_counter()

with ThreadPoolExecutor(max_workers=5) as executor:
    for url, status in zip(urls, executor.map(get_status, urls)):
        print(status, url)

elapsed = time.perf_counter() - start
print(f'Total time elapsed: {elapsed:.2f} seconds')

We get the status codes of the URLs with five threads.

with ThreadPoolExecutor(max_workers=5) as executor:
    for url, status in zip(urls, executor.map(get_status, urls)):

The map method returns the results in the order of the input, regardless of which request finished first. The with statement waits for all threads to finish.

The total time is now close to the longest delay of three seconds.

$ python threaded_map.py
200 https://httpbin.org/delay/2
200 https://httpbin.org/delay/1
200 https://httpbin.org/delay/3
200 https://httpbin.org/delay/1
200 https://httpbin.org/delay/2
Total time elapsed: 3.79 seconds

Threads with as_completed

To process results as soon as they arrive, we submit the calls individually and iterate over the futures with as_completed.

threaded.py
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests

urls = [f'https://httpbin.org/delay/{d}' for d in (2, 1, 3, 1, 2)]

def get_status(url):
    resp = requests.get(url, timeout=10)
    return resp.status_code

start = time.perf_counter()

with ThreadPoolExecutor(max_workers=5) as executor:
    futures = {executor.submit(get_status, url): url for url in urls}

    for future in as_completed(futures):
        url = futures[future]
        elapsed = time.perf_counter() - start
        print(f'{elapsed:5.2f}s {future.result()} {url}')

elapsed = time.perf_counter() - start
print(f'Total time elapsed: {elapsed:.2f} seconds')

We print each result with the time at which it became available.

futures = {executor.submit(get_status, url): url for url in urls}

The submit method schedules a call and returns a Future. We map each future to its URL so that we know which request a result belongs to.

for future in as_completed(futures):
    url = futures[future]
    ...
    print(f'{elapsed:5.2f}s {future.result()} {url}')

The as_completed function yields futures in the order they finish. The result method returns the value of the call or re-raises its exception.

$ python threaded.py
 1.73s 200 https://httpbin.org/delay/1
 1.73s 200 https://httpbin.org/delay/1
 2.75s 200 https://httpbin.org/delay/2
 2.75s 200 https://httpbin.org/delay/2
 3.77s 200 https://httpbin.org/delay/3
Total time elapsed: 3.78 seconds

Async requests with httpx

The httpx library provides an asynchronous client. Coroutines are run concurrently with asyncio.gather.

mul_async.py
import asyncio
import time
import httpx

urls = [f'https://httpbin.org/delay/{d}' for d in (2, 1, 3, 1, 2)]

async def main():
    async with httpx.AsyncClient(timeout=10) as client:
        resps = await asyncio.gather(*(client.get(url) for url in urls))

    for resp in resps:
        print(resp.status_code, resp.url)

start = time.perf_counter()
asyncio.run(main())
elapsed = time.perf_counter() - start
print(f'Total time elapsed: {elapsed:.2f} seconds')

We send all requests from a single thread.

async with httpx.AsyncClient(timeout=10) as client:

We create one client and share it among all requests. The client keeps a pool of connections; creating a new client for each request would throw this benefit away.

resps = await asyncio.gather(*(client.get(url) for url in urls))

Each client.get call returns a coroutine. The gather function runs them concurrently and returns their results in the order of the arguments.

$ python mul_async.py
200 https://httpbin.org/delay/2
200 https://httpbin.org/delay/1
200 https://httpbin.org/delay/3
200 https://httpbin.org/delay/1
200 https://httpbin.org/delay/2
Total time elapsed: 3.76 seconds

One client for all requests

A common mistake is to create a new client inside the coroutine that sends a request:

async def get_async(url):
    async with httpx.AsyncClient() as client:
        return await client.get(url)

resps = await asyncio.gather(*map(get_async, urls))

This code works and returns correct results, but it is wasteful. A client is not just a function for sending requests; it is an object with state:

When each request gets its own client, the pool is thrown away after a single request, the certificates are loaded again for every client, and there is no global limit on the number of open connections. With a thousand URLs, the program would open a thousand connections at once, which can exhaust file descriptors or get the client blocked by the server. The next example measures the difference.

client_reuse.py
import asyncio
import logging
import time
import httpx

url = 'https://example.com'

class ConnectionCounter(logging.Handler):

    def __init__(self):
        super().__init__()
        self.count = 0

    def emit(self, record):
        if record.getMessage().startswith('connect_tcp.complete'):
            self.count += 1

counter = ConnectionCounter()
logger = logging.getLogger('httpcore.connection')
logger.setLevel(logging.DEBUG)
logger.addHandler(counter)

async def fetch_with_new_client(url):
    async with httpx.AsyncClient() as client:
        return await client.get(url)

async def client_per_request(waves, size):
    for _ in range(waves):
        await asyncio.gather(*(fetch_with_new_client(url) for _ in range(size)))

async def shared_client(waves, size):
    async with httpx.AsyncClient() as client:
        for _ in range(waves):
            await asyncio.gather(*(client.get(url) for _ in range(size)))

for waves, size in [(1, 20), (4, 5)]:
    for strategy in (client_per_request, shared_client):
        counter.count = 0
        start = time.perf_counter()
        asyncio.run(strategy(waves, size))
        elapsed = time.perf_counter() - start
        print(f'{strategy.__name__:<18} {waves} x {size:<2}  {elapsed:4.2f}s  '
              f'{counter.count:>2} connections')

We send twenty requests to the same host with both approaches and count the TCP connections that are opened.

class ConnectionCounter(logging.Handler):
    ...
    def emit(self, record):
        if record.getMessage().startswith('connect_tcp.complete'):
            self.count += 1

httpx uses the httpcore library for the transport, which logs every new TCP connection at the DEBUG level. Our logging handler counts these messages instead of printing them.

async def fetch_with_new_client(url):
    async with httpx.AsyncClient() as client:
        return await client.get(url)

This is the wasteful pattern: a client is created, used for one request, and closed together with its connection.

async def shared_client(waves, size):
    async with httpx.AsyncClient() as client:
        for _ in range(waves):
            await asyncio.gather(*(client.get(url) for _ in range(size)))

Here one client serves all requests. Its connections stay in the pool between the waves.

for waves, size in [(1, 20), (4, 5)]:

We run two scenarios: one burst of twenty requests, and four waves of five requests, where each wave waits for the previous one to finish.

In a single burst, both approaches open twenty connections. All requests start at the same moment, so there is no idle connection to reuse yet; the times are similar and vary from run to run with the network. In the four waves, the shared client opens five connections in the first wave and reuses them in the next three. The client-per-request version opens a new connection for every request and takes about twice as long. The more requests a program sends to the same host over time, such as calls to one API or pages of one website, the larger the difference.

$ python client_reuse.py
client_per_request 1 x 20  0.44s  20 connections
shared_client      1 x 20  0.33s  20 connections
client_per_request 4 x 5   0.85s  20 connections
shared_client      4 x 5   0.40s   5 connections

Async requests with aiohttp

The aiohttp library is another popular asynchronous HTTP client.

aiohttp_async.py
import asyncio
import time
import aiohttp

urls = [f'https://httpbin.org/delay/{d}' for d in (2, 1, 3, 1, 2)]

async def fetch(session, url):
    async with session.get(url) as resp:
        data = await resp.json()
        return resp.status, data['url']

async def main():
    timeout = aiohttp.ClientTimeout(total=10)
    async with aiohttp.ClientSession(timeout=timeout) as session:
        results = await asyncio.gather(*(fetch(session, url) for url in urls))

    for status, url in results:
        print(status, url)

start = time.perf_counter()
asyncio.run(main())
elapsed = time.perf_counter() - start
print(f'Total time elapsed: {elapsed:.2f} seconds')

We read the status and the JSON body of each response.

async def fetch(session, url):
    async with session.get(url) as resp:
        data = await resp.json()
        return resp.status, data['url']

In aiohttp, the response is a context manager and the body is read with await. Leaving the block releases the connection back to the pool.

timeout = aiohttp.ClientTimeout(total=10)

Timeouts are configured with a ClientTimeout object.

$ python aiohttp_async.py
200 https://httpbin.org/delay/2
200 https://httpbin.org/delay/1
200 https://httpbin.org/delay/3
200 https://httpbin.org/delay/1
200 https://httpbin.org/delay/2
Total time elapsed: 3.83 seconds

Async results as they complete

The asyncio.as_completed function is the async counterpart of concurrent.futures.as_completed. Since Python 3.13, it can be used with async for.

async_completed.py
import asyncio
import time
import httpx

urls = [f'https://httpbin.org/delay/{d}' for d in (2, 1, 3, 1, 2)]

async def main():
    start = time.perf_counter()

    async with httpx.AsyncClient(timeout=10) as client:
        tasks = [asyncio.create_task(client.get(url)) for url in urls]

        async for task in asyncio.as_completed(tasks):
            resp = await task
            elapsed = time.perf_counter() - start
            print(f'{elapsed:5.2f}s {resp.status_code} {resp.url}')

asyncio.run(main())

We print each response when it arrives.

tasks = [asyncio.create_task(client.get(url)) for url in urls]

async for task in asyncio.as_completed(tasks):
    resp = await task

With async for, the loop yields the original task objects in the order of completion. The fastest responses are processed first.

$ python async_completed.py
 1.58s 200 https://httpbin.org/delay/1
 1.59s 200 https://httpbin.org/delay/1
 2.73s 200 https://httpbin.org/delay/2
 2.73s 200 https://httpbin.org/delay/2
 3.57s 200 https://httpbin.org/delay/3

Limiting concurrency

Sending hundreds of requests at once can overload a server or get the client blocked. An asyncio.Semaphore limits the number of requests in flight.

semaphore.py
import asyncio
import time
import httpx

urls = [f'https://httpbin.org/delay/1?id={i}' for i in range(1, 7)]

async def fetch(client, sem, url, start):
    async with sem:
        begin = time.perf_counter() - start
        resp = await client.get(url)
        end = time.perf_counter() - start
        print(f'{resp.url.params["id"]}: {begin:4.2f}s - {end:4.2f}s')
        return resp.status_code

async def main():
    sem = asyncio.Semaphore(2)
    start = time.perf_counter()

    async with httpx.AsyncClient(timeout=10) as client:
        async with asyncio.TaskGroup() as tg:
            tasks = [tg.create_task(fetch(client, sem, url, start)) for url in urls]

    print([t.result() for t in tasks])
    print(f'Total time elapsed: {time.perf_counter() - start:.2f} seconds')

asyncio.run(main())

We send six requests, but at most two at a time.

async with sem:
    begin = time.perf_counter() - start
    resp = await client.get(url)

A task waits at async with sem until one of the two slots is free. The output shows the requests running in three waves.

async with asyncio.TaskGroup() as tg:
    tasks = [tg.create_task(fetch(client, sem, url, start)) for url in urls]

A TaskGroup waits for all tasks to finish. If one of them fails, it cancels the others and raises an ExceptionGroup.

$ python semaphore.py
1: 0.02s - 1.55s
2: 0.03s - 1.57s
3: 1.55s - 2.72s
4: 1.57s - 2.74s
5: 2.72s - 3.89s
6: 2.74s - 3.92s
[200, 200, 200, 200, 200, 200]
Total time elapsed: 3.92 seconds

Handling errors

Concurrent requests fail independently. Handling errors inside each coroutine keeps one failure from affecting the other results.

errors.py
import asyncio
import httpx

urls = [
    'https://example.com',
    'https://httpbin.org/status/404',
    'https://httpbin.org/delay/5',
    'https://no-such-host.invalid',
]

async def check(client, url):
    try:
        resp = await client.get(url)
        resp.raise_for_status()
        return f'OK {resp.status_code}'
    except httpx.HTTPStatusError as e:
        return f'HTTP error {e.response.status_code}'
    except httpx.TimeoutException:
        return 'timed out'
    except httpx.ConnectError as e:
        return f'connection failed: {type(e).__name__}'

async def main():
    async with httpx.AsyncClient(timeout=2) as client:
        results = await asyncio.gather(*(check(client, url) for url in urls))

    for url, result in zip(urls, results):
        print(f'{url:32} {result}')

asyncio.run(main())

We check four URLs: one works, one returns 404, one is too slow, and one has a host name that does not exist.

resp = await client.get(url)
resp.raise_for_status()

httpx does not treat 4xx and 5xx responses as errors; the raise_for_status method turns them into an HTTPStatusError.

except httpx.TimeoutException:
    return 'timed out'
except httpx.ConnectError as e:
    return f'connection failed: {type(e).__name__}'

The two-second client timeout cuts off the five-second delay. A host name that cannot be resolved raises a ConnectError.

$ python errors.py
https://example.com              OK 200
https://httpbin.org/status/404   HTTP error 404
https://httpbin.org/delay/5      timed out
https://no-such-host.invalid     connection failed: ConnectError

First response wins

Sometimes we need only the fastest of several responses, for example when the same file is available on several mirrors. Python 3.15 adds TaskGroup.cancel, which cancels all remaining tasks of a group and lets the async with block exit normally.

first_wins.py
import asyncio
import time
import httpx

mirrors = {
    'mirror-a': 'https://httpbin.org/delay/3',
    'mirror-b': 'https://httpbin.org/delay/1',
    'mirror-c': 'https://httpbin.org/delay/2',
}

async def fetch(client, tg, name, url, result):
    try:
        resp = await client.get(url)
    except asyncio.CancelledError:
        print(f'{name} cancelled')
        raise
    result['winner'] = name, resp.status_code
    tg.cancel()

async def main():
    result = {}
    start = time.perf_counter()

    async with httpx.AsyncClient(timeout=10) as client:
        async with asyncio.TaskGroup() as tg:
            for name, url in mirrors.items():
                tg.create_task(fetch(client, tg, name, url, result))

    print('winner:', result['winner'])
    print(f'Total time elapsed: {time.perf_counter() - start:.2f} seconds')

asyncio.run(main())

We request the same resource from three mirrors and keep the first response.

result['winner'] = name, resp.status_code
tg.cancel()

The first task to finish stores its result and cancels the group. The other tasks receive a CancelledError at their await; they print a message and re-raise it, as cancelled tasks should.

async with asyncio.TaskGroup() as tg:
    for name, url in mirrors.items():
        tg.create_task(fetch(client, tg, name, url, result))

Cancelling with tg.cancel does not raise an exception out of the async with block. The program finishes in about the time of the fastest mirror. More on the method is in Python async/await.

$ python first_wins.py
mirror-a cancelled
mirror-c cancelled
winner: ('mirror-b', 200)
Total time elapsed: 1.62 seconds

Source

Python concurrent execution - documentation

In this article we have shown how to send concurrent HTTP requests in Python.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Python tutorials.