Python has two HTTP clients built in, urllib.request and http.client. Four more are popular on PyPI: urllib3, requests, httpx and aiohttp. They all send the same requests in the end, but their defaults differ more than their features.
We ran all six against one test server on 12 October 2026, with Python 3.12.3, and left every setting at its default. httpx gave up on a silent server after 5 seconds and urllib3's request() after 12. The other four were still waiting when we stopped them. One httpx habit also made it 18 times slower than it needed to be.
The six at a glance
| Client | Install | Sync or async | Followed our redirect | Raised on our 404 | On a server that never answers |
|---|---|---|---|---|---|
| urllib.request | built in | sync | yes | yes | still waiting at 15 s |
| http.client | built in | sync | no | no | still waiting at 15 s |
| urllib3 | 1 package, 0.9 MB | sync | yes | no | gave up at 12.1 s with request() |
| requests | 5 packages, 3.1 MB | sync | yes | no | still waiting at 15 s |
| httpx | 7 packages, 3.9 MB | both | no | no | gave up at 5.1 s |
| aiohttp | 10 packages, 13.6 MB | async | yes | no | still waiting at 15 s |
One request in each client
Each of these asked example.com for its home page. That domain exists for examples like these: IANA keeps it "for documentation purposes". Each one sets a timeout, for reasons the timeout section below shows.
urllib.request comes with Python:
import urllib.request
with urllib.request.urlopen("https://example.com/", timeout=10) as r:
print(r.status, len(r.read()))
http.client is the layer underneath it. You open the connection and send the path yourself. Python's own documentation says "It is normally not used directly", and adds: "The Requests package is recommended for a higher-level HTTP client interface".
import http.client
conn = http.client.HTTPSConnection("example.com", timeout=10)
conn.request("GET", "/")
r = conn.getresponse()
print(r.status, len(r.read()))
conn.close()
urllib3 keeps pools of open connections. requests is built on it.
import urllib3
pool = urllib3.PoolManager(timeout=10)
r = pool.request("GET", "https://example.com/")
print(r.status, len(r.data))
requests is the one most tutorials use. A Session keeps connections open between calls:
import requests
with requests.Session() as session:
r = session.get("https://example.com/", timeout=10)
print(r.status_code, len(r.content))
httpx looks like requests, and adds an async mode and HTTP/2. A Client plays the part of a Session:
import httpx
with httpx.Client(timeout=10) as client:
r = client.get("https://example.com/")
print(r.status_code, len(r.content))
aiohttp is async only, so every call runs inside an event loop:
import asyncio
import aiohttp
async def main():
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get("https://example.com/") as r:
print(r.status, len(await r.read()))
asyncio.run(main())
All six printed the same line:
200 577
What each one sends
Our test server listed the headers each client sent, with every setting left alone:
| Client | User-Agent | Accept-Encoding |
|---|---|---|
| urllib.request | Python-urllib/3.12 | identity |
| http.client | none | identity |
| urllib3 | python-urllib3/2.8.0 | identity |
| requests | python-requests/2.34.2 | gzip, deflate |
| httpx | python-httpx/0.28.1 | gzip, deflate |
| aiohttp | Python/3.12 aiohttp/3.14.5 | gzip, deflate |
The three lower-level clients ask for no compression, so pages arrive at full size. http.client sends no User-Agent at all, and urllib.request adds Connection: close, so it never reuses a connection. Our guide to request headers covers what to send instead.
The defaults that bite
Redirects
We asked each client for a page that answers with a 302 to another page. Four followed it and returned the second page with a 200. http.client and httpx returned the 302 itself. httpx does this on purpose: "By default, HTTPX will not follow redirects for all HTTP methods, although this can be explicitly enabled". Pass follow_redirects=True when you want the target page.
Errors
Only urllib.request raised an exception for a 404. Python's documentation describes the chain: "Eventually, HTTPDefaultErrorHandler will raise an HTTPError if no other handler handles the error". The other five returned the 404 as a normal answer, so check the status, or call raise_for_status() in requests and httpx.
With nothing listening on the port, all six failed at once. Each raised its own exception: URLError, ConnectionRefusedError, urllib3's MaxRetryError, requests' ConnectionError, httpx's ConnectError and aiohttp's ClientConnectorError.
Timeouts
The test that matters most: a server that takes the request and never answers. We set no timeout and stopped each client after 15 seconds:

- httpx gave up after 5.1 seconds with a
ReadTimeout. Its documentation: "The default behavior is to raise a TimeoutException after 5 seconds of network inactivity". - urllib3 gave up after 12.1 seconds, but only through the quick
urllib3.request()function. That function waits 3 seconds per try (its signature saystimeout = 3) and tries again three times by default. APoolManageryou create yourself has no timeout, and it was still waiting at 15 seconds. - requests was still waiting. Its documentation: "By default, requests do not time out unless a timeout value is set explicitly".
- aiohttp was still waiting too. Its limit is 5 minutes: "By default aiohttp uses a total 300 seconds (5min) timeout".
- urllib.request and http.client were still waiting. Without a timeout they use the socket default, and Python's documentation says "A value of None indicates that new socket objects have no timeout".
So set a timeout in every client, as the examples above do. A scraper with no timeout can stop for good on one bad server.
Speed: one request after another
We sent 1,000 requests in a row to a small page on the same machine, three times per setup, and kept the middle run:
| Setup | 1,000 requests | Processor time |
|---|---|---|
| http.client, one connection | 1.00 s | 0.53 s |
| aiohttp, ClientSession | 1.03 s | 0.49 s |
| urllib3, PoolManager | 1.12 s | 0.59 s |
| urllib.request, urlopen() each time | 1.84 s | 0.81 s |
| httpx, Client | 1.95 s | 1.30 s |
| requests, Session | 2.35 s | 1.57 s |
| requests.get() each time | 3.39 s | 2.23 s |
| httpx.get() each time | 35.82 s | 32.20 s |
httpx.get() in a loop was 18 times slower than the same requests through one httpx.Client. The cost sits in creating the client. Creating an httpx.Client took 23.7 ms, and building an SSL context from the certifi certificate file took 23.9 ms. A requests Session took 0.011 ms. The top-level httpx.get() creates a new Client on every call: its source runs with Client( for each request. httpx's documentation says it plainly: "If you do anything more than experimentation, one-off scripts, or prototypes, then you should use a Client instance".
requests pays less for the same habit: requests.get() in a loop took 3.39 seconds against 2.35 with a Session. Keep one Session or Client and reuse it.
These requests went to a server on the same machine, so they show each library's own work. On a real network, each request also waits for the round trip. The next test adds that wait.
Speed: many requests at once
Here every request waited 100 ms for its answer, the way a slow site or a far-away server does. We sent 200 of them:
One after another, 200 waits of 100 ms add up: 23.54 seconds. Twenty threads cut that to 1.73 seconds. The two async clients went further, because one process can keep many requests waiting at the same time.
Both async clients open at most 100 connections by default. aiohttp's documentation lists the limit as "(default: 100)", and httpx's as "(Default 100)". So 200 requests need at least two rounds of 100 ms, 0.2 seconds in all. aiohttp came close, with 0.32 seconds.
httpx's AsyncClient took 0.96 seconds and used 0.81 seconds of processor time, where aiohttp used 0.08. So httpx spent its time on its own work, not on waiting for the server. Many requests at once is aiohttp's strength, and the numbers show it.
Both async examples follow the same pattern. With aiohttp, the requests run side by side with asyncio.gather():
import asyncio
import aiohttp
URLS = [
"https://hproxy.com/blog/web-scraping-with-python",
"https://hproxy.com/blog/python-requests-retry",
"https://hproxy.com/blog/python-requests-headers",
]
async def fetch(session, url):
async with session.get(url) as r:
await r.read()
return r.status, url
async def main():
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session:
for status, url in await asyncio.gather(*(fetch(session, url) for url in URLS)):
print(status, url)
asyncio.run(main())
200 https://hproxy.com/blog/web-scraping-with-python
200 https://hproxy.com/blog/python-requests-retry
200 https://hproxy.com/blog/python-requests-headers
Keep the number of requests to one site modest, whatever the client can do. Our guide to fixing 429 errors explains what happens when a site decides you are too fast.
HTTP/2
We asked our own site, which speaks HTTP/2, for one small file with each client. requests and aiohttp used HTTP/1.1, and so did httpx by default. Only httpx with http2=True used HTTP/2. Its documentation explains that "HTTP/2 support is not enabled by default", and it needs the extra package: pip install "httpx[http2]".
import httpx
with httpx.Client(http2=True, timeout=10) as client:
r = client.get("https://hproxy.com/robots.txt")
print(r.status_code, r.http_version)
200 HTTP/2
What you install
We installed each library into a fresh environment and counted what came with it. Then we measured the processor time its import added to a bare Python start, as the middle of seven runs:
| Library | Packages | Size | Import |
|---|---|---|---|
| urllib.request, http.client | 0, built in | 0 | 59 ms and 44 ms |
| urllib3 | 1 | 0.9 MB | 137 ms |
| requests | 5 | 3.1 MB | 173 ms |
| httpx | 7 | 3.9 MB | 122 ms |
| httpx with HTTP/2 | 10 | 4.9 MB | 112 ms |
| aiohttp | 10 | 13.6 MB | 325 ms |
aiohttp is the heaviest to install and to import. For a long-running scraper that hardly matters. For a short command-line tool that starts thousands of times, it can.
Which one to use
- A quick script, nothing to install: urllib.request. Set a timeout, and catch
HTTPError, because it raises one for error answers such as a 404. - Most scripts and scrapers: requests with a Session, or httpx with a Client. httpx gives you a 5 second default timeout, HTTP/2 and an async mode in one library. Never call
httpx.get()in a loop. - Hundreds of pages at once: aiohttp was the fastest in our test, and the lightest on the processor. httpx's AsyncClient is the choice if you want one library for both styles.
- Building your own tool or library: urllib3, or http.client for full control.
Whichever you pick, our requests, httpx and aiohttp proxy guide shows how each of the three takes a proxy. Our retry guide covers what to do when a request fails.
What this page could not check
- The test server ran on the same machine as the clients. Real servers add network delay, TLS and their own quirks.
- The times are the middle of three runs on one server, and they move between runs. In three full runs,
httpx.get()in a loop took 35.15 to 37.82 seconds. Compare the ratios, not the milliseconds. - HTTP/2 was tested against one site, our own, with three clients. urllib3's own HTTP/2 support was not tested.
- Brotli and zstandard compression were not installed, so no client asked for them.
- We will rerun these tests by 12 January 2027.
Sources
- IANA, example domains: iana.org.
- Requests documentation, quickstart and advanced usage (timeouts): requests.readthedocs.io.
- HTTPX documentation: timeouts, clients, resource limits, HTTP/2 and the quickstart on redirects.
- aiohttp documentation: client quickstart (timeouts) and client reference (connection limit).
- urllib3 documentation, urllib3.request(): urllib3.readthedocs.io.
- Python 3.12 documentation: urllib.request, http.client and socket.
- Our own tests on our server, 12 October 2026: clients_test.py at 03:35 UTC and the code on this page at 03:35 UTC, against a test server of our own, example.com and hproxy.com.




