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.