

Key takeaways
requests and urllib3 are both Python HTTP clients, and requests builds on urllib3, so it uses urllib3 as its underlying engine. requests gives you a higher-level, readable API: sessions and JSON handling come built in, and responses are decoded for you. urllib3 exposes lower-level control over connection pools and retry behavior. Most projects use requests; reach for urllib3 when you need fine-grained transport control.
| Dimension | requests | urllib3 |
|---|---|---|
| API level | high-level, human-readable | low-level, explicit |
| Lines for a simple GET | one line | one line with urllib3.request(), a few more with a reusable PoolManager |
| Sessions and cookies | built in | manual |
| JSON handling | built in with .json() | json= argument and a basic .json() helper (2.x) |
| Connection pooling | automatic | explicit via PoolManager |
| Retries | off by default, added through an adapter | 3 by default, configurable Retry object |
| TLS control | standard | fine-grained |
| Proxy setup | proxies dictionary | ProxyManager |
| Async | no | no |
| HTTP/2 and HTTP/3 | no | experimental HTTP/2 only |
| Install | pip install requests | pip install urllib3 |
| Best for | readable everyday HTTP | low-level transport control |
requests is built on top of urllib3 and uses it as its underlying HTTP engine. When you install requests with pip, urllib3 is installed automatically as a dependency. requests adds a friendly, high-level API on top of urllib3's connection pooling and transport layer, so a call like requests.get() runs through urllib3 beneath the surface.
Think of it like a car. requests is the vehicle you drive. urllib3 is the engine under the hood, handling sockets and connection reuse. The requests documentation says it directly: keep-alive and connection pooling are "100% automatic, thanks to urllib3."
So why would anyone use urllib3 directly? It's a fair question, and the short answer is control. requests makes sensible choices for you, and most of the time those choices are exactly right. But when you're writing a library that other people install, or a long-running service where you want to tune every pool and retry yourself, dropping one layer down saves you a dependency and gives you the dials directly.
Run pip show requests and you'll see urllib3 listed under "Requires." It's the same urllib3 that sits under a large part of the Python ecosystem. Whether you call a public API or route traffic through Proxy-Cheap, urllib3 is the layer that opens the connection.
The core difference is abstraction level. requests offers a concise, human-readable API where sessions and JSON decoding are built in, which keeps everyday code short. urllib3 exposes lower-level controls: you configure the PoolManager and retry behavior yourself, trading a few extra lines for finer control. Because requests wraps urllib3, both share the same connection handling underneath.
Here's a GET with a query string and a POST with a JSON body in requests:
python import requests r = requests.get("https://httpbin.org/get", params={"q": "proxies"}, timeout=10) print(r.status_code) # 200 print(r.json()["args"]) # {'q': 'proxies'} r = requests.post("https://httpbin.org/post", json={"plan": "pay-as-you-go"}, timeout=10) print(r.json()["json"]) # {'plan': 'pay-as-you-go'}
And the same two requests in urllib3:
python import json import urllib3 http = urllib3.PoolManager() resp = http.request("GET", "https://httpbin.org/get", fields={"q": "proxies"}, timeout=10.0) print(resp.status) # 200 print(json.loads(resp.data)["args"]) # {'q': 'proxies'} resp = http.request("POST", "https://httpbin.org/post", json={"plan": "pay-as-you-go"}, timeout=10.0) print(resp.json()["json"]) # {'plan': 'pay-as-you-go'}
The gap is small. urllib3 2.x closed much of the old gap. It added a json= argument and a .json() helper, plus a top-level urllib3.request() function for one-off calls. You still work with raw bytes in resp.data, though, and there's no automatic charset handling like requests gives you with r.text.
The bigger differences show up as your code grows. A requests Session keeps cookies and headers between calls without any extra work. In urllib3, you track cookies yourself. requests also raises clear exceptions with raise_for_status(), while urllib3 leaves you to check resp.status.
On TLS, requests uses sensible defaults and the certifi bundle. urllib3 lets you pass your own ssl_context, pin certificates, or set minimum TLS versions per pool. If you're building something like a proxy API client in Python, requests gets you there faster, and urllib3 gets you closer to the wire.
requests and urllib3 deliver near-identical request speed, because every requests call runs through urllib3 underneath. The only overhead requests adds is a thin, high-level wrapper, which costs a fraction of a millisecond per call and is dwarfed by network latency. When one library is far faster than the other, the result usually reflects different test conditions.
Published numbers don't agree. One widely shared test put urllib3 about five times ahead over 100 requests, while another called the two equal. So we ran our own and published the test script so you can rerun it.
Setup: urllib3 2.8.0, requests 2.34.2, Python 3.13.16, on a 2 vCPU Linux machine with 8 GB of RAM. The target was a local httpbin server (gunicorn with keep-alive), so the network itself added almost nothing. Each client made 50 warm-up calls, then 1,000 timed calls, repeated over 5 rounds in random order.
python import random import statistics import time import requests import urllib3 URL = "http://127.0.0.1:8080/get" # local httpbin: pip install httpbin gunicorn N, ROUNDS, WARMUP = 1000, 5, 50 pool = urllib3.PoolManager() session = requests.Session() clients = { "urllib3 PoolManager": lambda: pool.request("GET", URL).data, "requests Session": lambda: session.get(URL).content, "requests.get, no Session": lambda: requests.get(URL).content, } def bench(fn): for _ in range(WARMUP): fn() times = [] for _ in range(N): start = time.perf_counter() fn() times.append(time.perf_counter() - start) return times results = {name: [] for name in clients} for _ in range(ROUNDS): for name in random.sample(list(clients), k=len(clients)): results[name] += bench(clients[name]) for name, times in results.items(): p95 = statistics.quantiles(times, n=20)[18] print(f"{name:26} median {statistics.median(times) * 1000:.3f} ms p95 {p95 * 1000:.3f} ms")
We ran it twice: once over plain HTTP and once over HTTPS with the same server behind a TLS certificate.
| Client | Median, HTTP | Median, HTTPS | p95, HTTPS |
|---|---|---|---|
| urllib3 PoolManager | 1.11 ms | 1.18 ms | 1.71 ms |
| requests Session | 1.44 ms | 1.55 ms | 2.26 ms |
| requests.get(), no Session | 1.75 ms | 5.54 ms | 7.98 ms |
The pooled results match the architecture. requests with a Session added about 0.35 ms per call over raw urllib3, and that's the wrapper building a PreparedRequest, merging settings, and wrapping the response. On a local server with a 1 ms round trip, that looks like 30%. Against a real site 50 ms away, it's under 1%.
The interesting row is the last one. Calling requests.get() in a loop creates a fresh session every time, so each HTTPS call pays for a new TCP connection and TLS handshake. That made it 4.7 times slower than pooled urllib3 even on localhost. Over a real network, each handshake costs extra round trips, so the gap grows. That's the most likely reason some tests show urllib3 far ahead: they compare a pooled urllib3 client to requests without a Session.
For heavy, long-running jobs on unlimited bandwidth proxies, reusing connections matters far more than which library you pick.
Both libraries route traffic through proxies. In requests, you pass a proxies dictionary that maps http and https to your proxy URL. In urllib3, you create a ProxyManager instead of a PoolManager, because urllib3 ignores proxy environment variables. Both support HTTP and SOCKS5 proxies, so the same residential or datacenter endpoint works with either library once the credentials are set.
requests. Put the username and password straight into the proxy URL, then pass the dictionary with each call:
python import requests proxy = "http://USERNAME:PASSWORD@PROXY_HOST:PORT" proxies = {"http": proxy, "https": proxy} r = requests.get("https://httpbin.org/ip", proxies=proxies, timeout=15) print(r.json()) # {'origin': '<the proxy exit IP>'}
One snag we hit in testing: if HTTPS_PROXY is set in your environment, it overrides Session.proxies. Passing proxies= on each call (as above) always wins, or you can set session.trust_env = False.
urllib3. Swap PoolManager for ProxyManager. Here's the detail most guides miss: in our tests, urllib3 2.8.0 did not send credentials written into the proxy URL, and the proxy answered with "407 Proxy Authentication Required." Pass them as a header instead:
python import urllib3 proxy = urllib3.ProxyManager( "http://PROXY_HOST:PORT", proxy_headers=urllib3.make_headers(proxy_basic_auth="USERNAME:PASSWORD"), ) resp = proxy.request("GET", "https://httpbin.org/ip", timeout=15.0) print(resp.json()) # {'origin': '<the proxy exit IP>'}
Because urllib3 doesn't read HTTP_PROXY or HTTPS_PROXY, a plain PoolManager connects directly even when those variables are set. That's predictable, but surprising if you expected the system proxy to kick in.
SOCKS5. requests needs the extra: pip install requests[socks]. Then use a socks5h:// URL in the same dictionary, and the proxy resolves DNS. In urllib3, import SOCKSProxyManager from urllib3.contrib.socks. Unlike ProxyManager, it does read credentials from the URL:
python from urllib3.contrib.socks import SOCKSProxyManager proxy = SOCKSProxyManager("socks5h://USERNAME:PASSWORD@PROXY_HOST:PORT") resp = proxy.request("GET", "https://httpbin.org/ip", timeout=15.0) print(resp.json())
Teams use proxies for large-scale data collection from publicly available sources, location-accurate QA testing, and checking market-specific pricing. Rotating residential proxies give you a new IP per request or a sticky session, while datacenter proxies offer dedicated IPs with high throughput. You can pick exit countries from the global proxy locations list and follow the proxy setup guides for gateway details.
Whether you standardize on requests or drop down to urllib3, both send traffic through proxies the same way once your credentials are set. Proxy-Cheap offers rotating residential and datacenter proxies on pay-as-you-go pricing with no monthly commitment, so you can start small and scale as your project grows.
requests gives you a clean exception tree under requests.exceptions. Catch RequestException and you've covered timeouts and connection errors in one place. Add raise_for_status() and 4xx or 5xx responses become exceptions too.
urllib3 is more explicit. It retries 3 times by default, and you shape that with a urllib3 Retry reference object. requests retries nothing by default, so you borrow the same Retry class through an adapter:
python import requests from requests.adapters import HTTPAdapter from urllib3.util import Retry retry = Retry(total=3, backoff_factor=0.5, status_forcelist=[429, 500, 502, 503, 504]) with requests.Session() as s: s.mount("https://", HTTPAdapter(max_retries=retry)) try: r = s.get("https://httpbin.org/status/503", timeout=10) r.raise_for_status() except requests.exceptions.RequestException as e: print(type(e).__name__) # RetryError
In plain urllib3, it's urllib3.PoolManager(retries=retry), and a failure surfaces as MaxRetryError. If those failures happen at the proxy, check your host and port first. Our guide on how to find your proxy server address covers that.
Both libraries work fine with threads. urllib3's pools are thread-safe, and requests inherits that, though the usual advice is one Session per thread. Neither one is asynchronous, so concurrency means a thread pool or a different client.
Use requests for everyday work such as API calls and quick data-collection scripts, where readable code and built-in sessions save time. Use urllib3 when you need direct control over the transport layer, such as tuning connection pools for a long-running service or building a library other code depends on. For asynchronous work or HTTP/2, choose a different client such as HTTPX.
Here's the lookup table version:
| Task | Pick | Why |
|---|---|---|
| Quick script or one-off request | requests | least code |
| Talking to a REST API | requests | JSON and sessions built in |
| Large-scale data collection | requests (or urllib3 for tight pooling) | convenience vs control |
| Building a library or framework | urllib3 | no heavy dependency, direct control |
| Session-heavy, many hosts | urllib3 | explicit pool tuning |
| Async or HTTP/2 needed | neither; HTTPX (async and HTTP/2) or aiohttp (async) | native async |
A few real examples. A freelancer pulling rank data for SEO automation should use requests and get on with the day. A developer running a crawler that talks to hundreds of hosts for weeks may prefer urllib3, setting num_pools and maxsize by hand.
The proxy type often matters more than the library. Long logged-in sessions work well with static residential proxies, since the IP stays the same. If you want residential identity with datacenter speed, ISP proxies come in static and rotating options.
Sometimes neither library fits. That usually means you need async or a newer HTTP version.
HTTPX offers a broadly requests-compatible API with both sync and async clients. HTTP/2 support comes with the httpx[http2] extra. If you know requests, you'll feel at home in about five minutes.
aiohttp is async-first and ships both a client and a server framework, plus WebSocket support. It's a strong fit when your whole app already runs on asyncio, such as a high-volume market research data collection pipeline.
urllib3-future is a fork of urllib3 that adds HTTP/2, HTTP/3, and async interfaces. It's designed as a drop-in replacement, so be aware that it takes over the urllib3 import in your environment. niquests is a fork of requests that depends on urllib3-future. It keeps the familiar requests API and adds HTTP/2 by default, HTTP/3 over QUIC, and an async session.
When is a switch worth it? Only when you need async or HTTP/2 and HTTP/3. For plain synchronous work, requests and urllib3 are mature and fast enough.
urllib is part of Python's standard library and needs no install; in Python 3 it lives at urllib.request. urllib2 existed only in Python 2 and was folded into urllib for Python 3. urllib3 is a separate third-party package you install with pip, and despite the name it is not an upgrade of urllib. requests sits on top of urllib3 with a friendlier API.
| Name | Standard library or third-party | Python version | Typical use |
|---|---|---|---|
| urllib | standard library | Python 3 (urllib.request, urllib.parse), reorganized from Python 2 | quick calls with zero installs |
| urllib2 | standard library | Python 2 only | legacy code |
| urllib3 | third-party (pip) | Python 3.10+ for 2.8.0 | low-level HTTP with pooling |
| requests | third-party (pip) | Python 3.10+ for 2.34.2 | readable everyday HTTP |
The names suggest a version sequence, but there isn't one. urllib3 is an independent project that borrowed the familiar name. Even the Python urllib.request docs point readers to requests for a higher-level interface. If you're new to the networking side, our guide to proxy types explains HTTP and SOCKS5 in plain terms.
Installing requests pulls in urllib3 automatically:
bash pip install requests
To use urllib3 on its own, without requests:
bash pip install urllib3
For SOCKS5 proxy support in requests, add the extra (urllib3 has a matching urllib3[socks] extra):
bash pip install "requests[socks]"
Check that everything imports and see which versions you have:
bash python -c "import requests, urllib3; print(requests.__version__, urllib3.__version__)"
If you plan to manage proxies from code, the Proxy-Cheap API documentation shows how to list and rotate them programmatically.