httpx base_url: how Client joins paths (tested)
httpx.Client(base_url="https://host/api/v1") then c.get("users") or c.get("/users"): both request /api/v1/users. Unlike urljoin, a leading slash does not drop the base path.
httpx.Client(base_url=...) prefixes every relative request URL. Its joining rules are not the same as urllib.parse.urljoin, which is where most surprises come from. Measured with httpx 0.28.1 on Python 3.13.5; srv is a local http.server that echoes the path it received.
1. How httpx joins base_url and the path
import httpx, srv
url = srv.start()
with httpx.Client(base_url=url + "/api/v1") as c:
for path in ["users", "/users", "users/42?x=1", "../health"]:
r = c.get(path)
print(f"{path!r:16} -> server got {r.text}")
print("c.base_url =", c.base_url)
Output:
'users' -> server got /api/v1/users
'/users' -> server got /api/v1/users
'users/42?x=1' -> server got /api/v1/users/42?x=1
'../health' -> server got /api/health
c.base_url = http://127.0.0.1:8964/api/v1/
httpx adds a trailing slash to the base URL (c.base_url ends in /v1/), then appends the path. A leading slash on the path does not reset to the host root: "/users" and "users" both go to /api/v1/users. Query strings in the path are kept, and .. segments are resolved.
2. httpx base_url vs urljoin
from urllib.parse import urljoin
import httpx
base = "http://127.0.0.1:8964/api/v1"
for path in ["users", "/users"]:
print(f"{path!r:9} urljoin: {urljoin(base, path):38} httpx: {httpx.Client(base_url=base).build_request('GET', path).url}")
Output:
'users' urljoin: http://127.0.0.1:8964/api/users httpx: http://127.0.0.1:8964/api/v1/users
'/users' urljoin: http://127.0.0.1:8964/users httpx: http://127.0.0.1:8964/api/v1/users
urljoin drops the last path segment of a base without a trailing slash, and treats "/users" as host-absolute. If you are porting code that built URLs with urljoin, the resulting URLs can change.
3. Absolute URLs bypass base_url
import httpx, srv
url = srv.start()
with httpx.Client(base_url=url + "/api/v1") as c:
r = c.get("http://127.0.0.1:8964/other")
print("absolute URL ->", r.url)
r = c.get("")
print("empty path ->", r.url)
Output:
absolute URL -> http://127.0.0.1:8964/other
empty path -> http://127.0.0.1:8964/api/v1/
A full URL with a scheme ignores the base entirely; an empty path requests the base itself.
4. UnsupportedProtocol: Request URL is missing an 'http://' or 'https://' protocol
import httpx
try:
httpx.get("/users")
except Exception as e:
print(type(e).__name__ + ":", e)
c = httpx.Client()
try:
c.get("/users")
except Exception as e:
print(type(e).__name__ + ":", e)
c.base_url = "http://127.0.0.1:8964/api/"
print("set later:", c.build_request("GET", "users").url)
Output:
UnsupportedProtocol: Request URL is missing an 'http://' or 'https://' protocol.
UnsupportedProtocol: Request URL is missing an 'http://' or 'https://' protocol.
set later: http://127.0.0.1:8964/api/users
This is the error for a relative path with no base_url, including the module-level httpx.get() which has no base_url option. You can set client.base_url after creating the client; the same join rule applies.
5. AsyncClient base_url with client and request params
import asyncio, httpx, srv
url = srv.start()
async def main():
async with httpx.AsyncClient(base_url=url + "/api/v1/", params={"key": "k"}) as c:
r = await c.get("users", params={"page": 2})
print(r.text)
asyncio.run(main())
Output:
/api/v1/users?key=k&page=2
AsyncClient takes the same base_url. Client-level params are merged with per-request params (a ?query written into the path string behaves differently, see httpx params).
6. requests has no base_url
import requests
try:
requests.Session().get("/users")
except Exception as e:
print(type(e).__name__ + ":", e)
print("requests.Session has base_url:", hasattr(requests.Session(), "base_url"))
Output:
MissingSchema: Invalid URL '/users': No scheme supplied. Perhaps you meant https:///users?
requests.Session has base_url: False
Measured with requests 2.32.3. A requests.Session needs the full URL on every call (or a small subclass overriding request()); base_url is one of the reasons people switch to httpx.
Related: httpx params and query strings, httpx client headers, httpx MockTransport for tests, httpx vs requests differences.
Frequently asked questions
Does a leading slash drop the httpx base_url path?
No. With base_url ".../api/v1", both "/users" and "users" request /api/v1/users. urljoin would give /users and /api/users.
Why do I get UnsupportedProtocol: Request URL is missing an 'http://' or 'https://' protocol?
You passed a relative path without a base_url. Use httpx.Client(base_url=...) or a full URL.
Does requests.Session support base_url?
No. requests 2.32.3 raises MissingSchema for a relative URL; pass full URLs or subclass Session.
Written with AI assistance. Measured on 2026-10-23 with httpx 0.28.1 and requests 2.32.3 on Python 3.13.5 against a local http.server. Each block was run verbatim and the output pasted unedited. PlainHeaders is our extension; httpx is free.