To use a proxy with Python httpx, pass a proxy URL to the client as proxy=, not proxies=. The plural form was deprecated in httpx 0.26 and removed in 0.28, which is why so much working code suddenly raises TypeError: Client.__init__() got an unexpected keyword argument 'proxies'.
The httpx proxy setting in one line
httpx takes the proxy as a single URL, applied to every request the client makes:
import httpx
with httpx.Client(proxy="http://198.51.100.10:8080", timeout=10) as client:
r = client.get("https://api.ipify.org?format=json")
print(r.text)
The same argument exists on the top-level helpers and on the async client:
httpx.get("https://example.com/", proxy="http://198.51.100.10:8080")
async with httpx.AsyncClient(proxy="http://198.51.100.10:8080") as client:
r = await client.get("https://example.com/")
One URL covers both schemes. You do not need a separate entry for https://, and the proxy URL itself should almost always start with http:// even when the target is HTTPS, because the connection to the proxy is plain HTTP before the tunnel is opened. If the printed IP is the proxy exit rather than your own address, the route works. The proxy tester runs the same check in the browser when you want a second opinion without writing code.
proxies= vs proxy=: what changed, and in which version
The rename is the single biggest source of httpx proxy questions, and the answer depends on the version you have installed. We installed five httpx releases in isolated virtualenvs on Python 3.13.6 with OpenSSL 3.6.3, and called each form on 10 September 2026. This is what each version does:
| httpx | Client(proxies=...) | Client(proxy=...) | mounts= with a string |
|---|---|---|---|
| 0.23.3 | accepted, silent | TypeError, unexpected keyword argument 'proxy' | AttributeError, needs httpx.Proxy(...) |
| 0.25.2 | accepted, silent | TypeError, unexpected keyword argument 'proxy' | AttributeError, needs httpx.Proxy(...) |
| 0.26.0 | accepted, DeprecationWarning | accepted | accepted |
| 0.27.2 | accepted, DeprecationWarning | accepted | accepted |
| 0.28.1 | TypeError, unexpected keyword argument 'proxies' | accepted | accepted |
The deprecation text in 0.26 and 0.27 is exact and worth grepping your logs for: The 'proxies' argument is now deprecated. Use 'proxy' or 'mounts' instead. Because it is a DeprecationWarning, Python hides it by default outside __main__, so most projects never saw it and met the removal as a hard failure two releases later.
Three practical consequences follow from that table.
- If you own the code, replace
proxies="http://..."withproxy="http://..."and requirehttpx>=0.26. A dict of per-scheme proxies becomesmounts, covered below. - If a dependency raises it, the traceback usually points at a library that still passes
proxiesdown into httpx, and the file in the traceback is not yours. Upgrading that library is the fix; pinninghttpx<0.28only buys time. - A dict passed to the new argument is not a shortcut. On 0.26 and 0.27
proxy={"http://": ...}was accepted and routed anyway; on 0.28.1 the same line raisesAttributeError: 'dict' object has no attribute 'url', which reads like a bug in httpx and is not one.
Authentication, and what the proxy actually receives
Credentials go in the userinfo part of the proxy URL. There is no separate auth argument:
proxy = "http://USER:PASSWORD@proxy.example.net:8080"
with httpx.Client(proxy=proxy, timeout=10) as client:
r = client.get("https://example.com/")
Percent-encode any of @ : / # that appear in the password, or the URL parser will split the host in the wrong place. That single mistake is behind most 407 Proxy Authentication Required reports.
To see what httpx sends rather than guess, we pointed it at a socket-level stub that logs the first bytes of every connection. Same client, same credentials, two targets:
| Target URL | First line the proxy sees | Headers on that line |
|---|---|---|
http://example.com/ip | GET http://example.com/ip HTTP/1.1 | Host, Proxy-Authorization, Accept, Accept-Encoding, Connection, User-Agent |
https://example.com/ip | CONNECT example.com:443 HTTP/1.1 | Host, Accept, Proxy-Authorization only |
Two things are worth taking away. First, an HTTP target is forwarded in absolute form and the proxy reads the whole request, path and headers included; an HTTPS target is tunnelled and the proxy learns only the hostname and port. Second, the forwarded request carries User-Agent: python-httpx/0.28.1 unless you override it. A default client announces the library and its exact version to every site you touch, which is a fingerprint no proxy quality can hide. Set a realistic headers= dict on the client before you blame the IP pool.
Per-domain routing with mounts, and the per-request habit that breaks
httpx has no per-request proxy. client.get(url, proxy=...) raises TypeError: Client.get() got an unexpected keyword argument 'proxy' on every version we tested, 0.23.3 through 0.28.1. This is the main behavioural gap for people arriving from requests, where the proxy is a per-call argument. In httpx the proxy is a property of the client, so routing decisions are made when you build it:
transport = httpx.HTTPTransport(proxy="http://198.51.100.10:8080")
client = httpx.Client(mounts={
"all://internal.example.com": None, # never proxied
"all://": transport, # everything else
})
A None mount means direct. We verified the split against the stub: a request to the mounted host arrived at the proxy, a request to the excluded host went straight out and returned the real site's 404. Note that on 0.23 and 0.25 the transport wants httpx.Proxy("http://...") rather than a bare string, which is a second reason those versions are painful to support.
Rotation follows from the same rule. Because a client owns its proxy, you rotate by picking a client, not by picking a URL:
import itertools, httpx
# credentials go in each URL, exactly as shown above
exits = ["http://gate.example.net:8080?session=a",
"http://gate.example.net:8080?session=b"]
clients = itertools.cycle([httpx.Client(proxy=p, timeout=15) for p in exits])
for url in urls:
r = next(clients).get(url)
Building one client per request instead throws away the connection pool and, with it, most of the speed advantage of httpx. If what you actually want is a different IP per request, take it from the endpoint rather than from your code: rotating proxies give a fresh exit on every request through one host and one credential, and sticky sessions when a flow has to keep the same IP.
SOCKS5 with httpx
SOCKS support is an optional extra. Without it, the failure is explicit rather than mysterious: ImportError: Using SOCKS proxy, but the 'socksio' package is not installed. Install it and the same proxy= argument takes a SOCKS URL:
pip install "httpx[socks]"
with httpx.Client(proxy="socks5://USER:PASSWORD@proxy.example.net:1080") as client:
r = client.get("https://example.com/")
A detail that trips people migrating from requests: there, socks5 resolves DNS locally and socks5h resolves it at the proxy, and choosing wrong leaks your resolver or breaks internal hostnames. We ran both schemes against a SOCKS5 stub that decodes the connect request. Both sent address type 3 with the literal hostname example.com and port 443. In httpx, socks5 and socks5h behave the same way, and the name is always resolved at the exit. If you need SOCKS on a commercial pool, SOCKS5 proxies expose the same credentials over both protocols, so the switch is a scheme change and nothing else.
Environment variables and how to ignore them
httpx reads HTTP_PROXY, HTTPS_PROXY, ALL_PROXY and NO_PROXY by default. We confirmed both halves against the stub: with HTTP_PROXY set, a plain httpx.Client() routed through it with no argument at all, and adding NO_PROXY=example.com sent the next request direct.
That default is convenient on a laptop and dangerous in production, where a stray variable in a container image silently reroutes traffic. Two ways to be explicit:
httpx.Client(trust_env=False)ignores the environment entirely.httpx.Client(proxy=...)takes precedence over the variables, so an explicit proxy is never overridden by one.
When a request behaves differently inside a job runner than on your machine, print the environment before you debug the code. It is the most common cause of a "proxy not working" report that turns out to be a proxy working perfectly, for the wrong destination.
The errors you will actually hit
| Message | What it means |
|---|---|
TypeError: ... unexpected keyword argument 'proxies' | httpx 0.28 or newer. Rename to proxy, or upgrade the library that passes it. |
TypeError: ... unexpected keyword argument 'proxy' | httpx 0.25 or older. Upgrade, or use proxies on that version. |
AttributeError: 'dict' object has no attribute 'url' | A dict was passed to proxy=. Use mounts= for per-scheme routing. |
ImportError: ... 'socksio' package is not installed | A SOCKS URL without the extra. Install httpx[socks]. |
httpx.ProxyError | The proxy answered and refused: bad credentials, blocked target, exhausted plan. |
httpx.ConnectError | The proxy never answered: wrong port, wrong scheme, firewall, dead exit. |
httpx.ConnectTimeout on HTTPS only | The proxy accepts connections but does not honour CONNECT. |
The same taxonomy applies one library over: if you also maintain requests code, the exception classes differ but the causes do not, and the guide to requests.exceptions.ProxyError maps them the same way.
When the proxy layer stops being the interesting part
Everything above is plumbing. It matters right up to the point where the target starts returning challenges instead of pages, and then no amount of argument-renaming helps. At that point you are choosing between running the retry, header and fingerprint logic yourself on top of residential proxies, or handing the whole fetch to an API and paying only for the pages that come back. The web scraping API bills successful responses from $0.0002 per page and returns Markdown or structured JSON, which removes the client-side proxy question entirely; residential exits start at $0.80 per GB if you would rather keep the loop in your own code. Both sit on the same $2 of free monthly usage, which is enough to test either path before deciding.