Scraping Tools
November 2, 2026
5 min

urllib3 vs requests: which Python HTTP client to use

Alex Sadovskij
Alex Sadovskij
CEO Proxy-Cheap
urllib3 vs requests: which Python HTTP client to use
요약
Compare urllib3 and requests on syntax, speed, proxy setup and error handling, with a 2026 benchmark. Find out which Python HTTP client fits your project and when to switch to HTTPX or aiohttp.

Key takeaways

  • requests is built on top of urllib3 and calls it under the hood, so installing requests also installs urllib3.
  • requests wins on developer speed with readable syntax and built-in sessions, while urllib3 wins on control with explicit connection pooling and configurable TLS.
  • Neither library is asynchronous, and neither ships production-ready HTTP/2 or HTTP/3 (urllib3's HTTP/2 is still experimental). For those, look at HTTPX, or aiohttp for async. The urllib3-future and niquests forks are another route.
  • Both support proxies. requests uses a proxies dictionary; urllib3 uses a ProxyManager, and this guide shows the exact configuration for each.

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.

Dimensionrequestsurllib3
API levelhigh-level, human-readablelow-level, explicit
Lines for a simple GETone lineone line with urllib3.request(), a few more with a reusable PoolManager
Sessions and cookiesbuilt inmanual
JSON handlingbuilt in with .json()json= argument and a basic .json() helper (2.x)
Connection poolingautomaticexplicit via PoolManager
Retriesoff by default, added through an adapter3 by default, configurable Retry object
TLS controlstandardfine-grained
Proxy setupproxies dictionaryProxyManager
Asyncnono
HTTP/2 and HTTP/3noexperimental HTTP/2 only
Installpip install requestspip install urllib3
Best forreadable everyday HTTPlow-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.

urllib3 vs requests: side-by-side comparison

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.

Speed benchmark: urllib3 vs requests in 2026

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.

ClientMedian, HTTPMedian, HTTPSp95, HTTPS
urllib3 PoolManager1.11 ms1.18 ms1.71 ms
requests Session1.44 ms1.55 ms2.26 ms
requests.get(), no Session1.75 ms5.54 ms7.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.

Using proxies with urllib3 and requests

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.

Error handling, retries, and thread-safety

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.

When to use urllib3 vs requests

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:

TaskPickWhy
Quick script or one-off requestrequestsleast code
Talking to a REST APIrequestsJSON and sessions built in
Large-scale data collectionrequests (or urllib3 for tight pooling)convenience vs control
Building a library or frameworkurllib3no heavy dependency, direct control
Session-heavy, many hostsurllib3explicit pool tuning
Async or HTTP/2 neededneither; 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.

Alternatives: HTTPX, aiohttp, and the urllib3-future / niquests forks

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 vs urllib2 vs urllib3 vs requests, explained

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.

NameStandard library or third-partyPython versionTypical use
urllibstandard libraryPython 3 (urllib.request, urllib.parse), reorganized from Python 2quick calls with zero installs
urllib2standard libraryPython 2 onlylegacy code
urllib3third-party (pip)Python 3.10+ for 2.8.0low-level HTTP with pooling
requeststhird-party (pip)Python 3.10+ for 2.34.2readable 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.

How to install urllib3 and requests

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.

자주 묻는 질문

For almost all projects, requests is the better choice. It gives you a much cleaner API than Python's built-in urllib modules, with sessions and JSON handling built in and simpler error handling. Use plain urllib only when you must avoid third-party dependencies.

No. urllib3 is a third-party package you install with pip. The standard library ships urllib and its urllib.request module, which is separate from urllib3 despite the similar name.

urllib3 is currently on its 2.x release line, with 2.8.0 released in September 2026. Always confirm the exact latest version on PyPI before pinning it, because new releases ship regularly.

No. requests is a third-party library installed with pip install requests. Python does not bundle it, though many environments add it. Installing requests also installs urllib3 as a dependency.

urllib3 handles low-level HTTP work like connection pooling and TLS verification. It also manages retries and redirects for you. Many popular libraries, including requests, rely on it as their HTTP engine, and you can use it directly when you want explicit control over connections.

Developers use requests because it makes HTTP simple and readable. A single line sends a GET or POST, and features like sessions and JSON parsing work with almost no setup. That convenience is why it is one of the most downloaded Python packages.

There is no single best client; it depends on the task. requests suits most synchronous work, and urllib3 suits low-level control. For asynchronous workloads, HTTPX and aiohttp are better fits, and HTTPX also handles HTTP/2.

In practice, they are about the same speed, because requests sends its traffic through urllib3. Any measured gap comes from the thin wrapper requests adds and is usually smaller than normal network variation.

Yes. They coexist without conflict, and requests already depends on urllib3. Larger applications often use requests for everyday calls and urllib3 where they need direct control over connections or retry behavior.

No. When you install requests, pip installs urllib3 automatically as a dependency. You only install urllib3 on its own when you want to use it directly without requests.