Verbs¶
Every verb below exists on both HTTPClient and SyncHTTPClient. All examples assume:
Write paths without a leading /
Paths join onto base_url by the standard URL rules, so a leading / replaces the base's
own path instead of adding to it:
async with HTTPClient(base_url="https://api.example.com/v2/") as client:
await client.get("users") # https://api.example.com/v2/users
await client.get("/users") # https://api.example.com/users — the /v2/ is gone
base_url itself must end with / when it has a path (.../v2/, not .../v2); lothc
raises ValueError otherwise.
GET¶
response = await client.get("items/7")
response.data # the body as bytes, by default
response = await client.get(
"items/7",
params={"q": "pikachu", "page": 2},
headers={"x-custom": "header-value"},
response_data_type=ItemModel,
)
response.data # ItemModel(id=7, name="item-7")
Every verb returns a Response: the decoded body on .data, plus the status, headers and more
(see The Response below).
Typed params and headers¶
params/headers also accept a BaseModel/Struct instead of a plain mapping — fields set to
None are omitted rather than sent as "None":
from msgspec import Struct
from pydantic import BaseModel
class SearchParams(BaseModel):
q: str
page: int
limit: int | None = None # omitted from the query string entirely, not sent as "None"
class SearchStructParams(Struct):
q: str
page: int
cursor: str | None = None # same omission behavior, msgspec Struct instead of BaseModel
response = await client.get(
"items", params=SearchParams(q="pikachu", page=1), response_data_type=SearchResult
)
response = await client.get(
"items", params=SearchStructParams(q="pikachu", page=1), response_data_type=SearchResult
)
headers works the same way for outgoing request headers — pass a BaseModel/Struct instead
of a dict[str, str] and get the same None-omission for free.
Repeated query params¶
A list/tuple of str | int | float | bool in params sends that key once per element,
verbatim — lothc has no opinion on what an element means (it's never a key=value pair to lothc,
just another occurrence of the key):
await client.get("items", params={"tag": ["ready", "pending"], "limit": 10})
# -> ?tag=ready&tag=pending&limit=10
Top-level arrays and other shapes¶
response_data_type also accepts a pydantic TypeAdapter or a msgspec Decoder, which is how a
top-level JSON array (or any shape that isn't one model) gets a typed decode. Build them once and
reuse them:
from msgspec.json import Decoder
from pydantic import TypeAdapter
items_adapter = TypeAdapter(list[ItemModel])
response = await client.get("items", response_data_type=items_adapter)
response.data # list[ItemModel]
response = await client.get("items", response_data_type=Decoder(list[ItemStruct]))
response.data # list[ItemStruct]
response_data_type=dict is for a JSON object; decoding an array (or a string, number, null)
into it raises a ValueError that says what the body actually was.
The Response¶
get, post, put, patch, delete and head all return a Response:
response = await client.get("items/7", response_data_type=ItemModel)
response.data # ItemModel(id=7, name="item-7")
response.status # 200
response.headers # {"content-type": "application/json", ...}
response.request.method # "GET"
response.request.url # "https://api.example.com/items/7"
response.request.path # "/items/7"
response.request.host # "api.example.com"
response.http_version # "HTTP/1.1"
response.elapsed # 0.042: seconds from sending to having the whole response
response.elapsed includes any retries, since it spans the whole call.
response.request is the request actually sent — useful for logging, or for telling apart which
call a Response came from when you're juggling several. It reflects the target you asked for, not
necessarily the one a final response came from if redirects were followed.
response.headers is a CaseInsensitiveDict, so header names compare case-insensitively the way
HTTP itself defines them (RFC 9110 §5.1) — response.headers["Content-Type"],
response.headers["content-type"] and "CONTENT-TYPE" in response.headers are all the same lookup.
It's a MutableMapping, not a dict subclass (the same choice requests, niquests and httpx all
make), so isinstance(response.headers, dict) is False; iterating it yields keys with the casing
they arrived in, and only lookups, in and == ignore case.
A header sent more than once — Set-Cookie, most often — keeps every value. Indexing gives the
first, as it always has; get_all gives all of them, in arrival order, and [] for a header that
wasn't sent at all:
response.headers["set-cookie"]
# 'session=abc; Path=/'
response.headers.get_all("Set-Cookie")
# ['session=abc; Path=/', 'csrf=xyz; Path=/', 'theme=dark; Path=/']
response.headers.get_all("never-sent")
# []
Iteration, .items() and dict(response.headers) stay single-valued (one entry per header name,
its first value), so get_all is the only way to see a repeated header's extra values.
Equality follows the same split. Two header maps compare every value, so two responses that differ
only in a dropped Set-Cookie aren't equal. Against a plain dict, which can only hold one
value per name, only the first value of each is compared. So with repeated headers, equality isn't
transitive: two maps can each equal the same dict without equalling each other (requests'
CaseInsensitiveDict behaves the same way).
Pass response_headers_type (a BaseModel/Struct) to get the response headers validated and parsed
too, via response.typed_headers. Header names are lowercased and - becomes _ before matching
against your type's field names, so a Content-Type response header maps onto a content_type
field:
class ItemHeaders(BaseModel):
content_type: str | None = None
response = await client.get(
"items/7", response_data_type=ItemModel, response_headers_type=ItemHeaders
)
response.typed_headers.content_type # "application/json"
response_headers_type works the same way on head() — see below.
POST, PUT, PATCH¶
All three take the same body options — provide at most one of json, data, form or
content (passing more than one raises ValueError):
await client.post("items", json={"name": "new-item"})
await client.put("items/7", json=ItemModel(id=7, name="replaced"))
await client.patch("items/7", json={"name": "renamed"})
json also accepts a BaseModel/Struct directly (serialized for you, by alias for both
libraries: a pydantic model is encoded by its aliases unless it sets serialize_by_alias=False,
matching msgspec's rename=), or a top-level list
for a JSON array body; it always sends Content-Type: application/json, replacing any you set.
data sends a urlencoded body (application/x-www-form-urlencoded, what a plain HTML form
submits), as data= does in requests and httpx. It takes the same values as params: a dict
(a list/tuple value repeats the key, a bool goes out as true/false) or a
BaseModel/Struct, whose None fields are omitted:
form sends a multipart/form-data body: fields, JSON parts, raw bytes and files (see
below).
content sends a raw str/bytes body as-is and sets no Content-Type at all (unlike
httpx/requests, which default a str to text/plain), so pass your own in headers if the
server needs one:
Each returns a Response like get's, including the same response_headers_type option:
response = await client.post("items", json={"name": "new-item"}, response_data_type=ItemModel)
response.data # ItemModel(...)
response.status # 201
Multipart forms — form¶
form builds a real multipart/form-data body from a dict, one part per key. Each value's
type decides how it's sent:
str/int/float/bool— a plain form field. Aboolgoes out as"true"/"false", the same wayparams=sends one.bytes— a form field too (no filename), for raw binary data that isn't a "file" as such.list/dict(or aBaseModel/Structinstance) — JSON-encoded as that one part's body, withContent-Type: application/jsonset automatically.- A file, in one of four shapes:
pathlib.Path— read and streamed from disk; filename is the path's own.name.(filename, bytes | Path | file_object)— an explicit filename paired with the content.(filename, bytes | Path | file_object, content_type)— same, plus an explicit content-type override.(Path, content_type)— keeps the path's own auto-derived filename, but overrides just the content-type.- An already-opened binary file object (anything
io.BufferedIOBase, e.g.open(path, "rb")or aBytesIO) also works directly, filename taken from.nameif the object has one. - A
tupleof any of the above repeats that field name once per element — multiple parts, all sharing the same name (a realmultipart/form-datacapability, not something most HTTP client libraries expose). Note this is atuplespecifically, not alist— alistvalue always means "JSON-encode me as one part," never "repeat" and never a file, whatever it holds. Every element must be the same kind of value — all fields, all raw binary, all files, or all JSON. A mixed tuple is a type error, and raisesTypeErrorif you reach it at runtime anyway; an empty tuple raisesValueErrorrather than quietly contributing no parts at all. In particular(b"...", "image/png")is not a file paired with its content-type: only aPathcarries a filename of its own (hence the(Path, content_type)shape above), sobytesalways needs the explicit(filename, content, content_type)spelling instead.
By default, a file part's content-type is guessed from its filename's extension (via the stdlib
mimetypes module) unless you gave one explicitly. Pass infer_mime_type_from_file_extension=False
to disable guessing — the part then goes out with no Content-Type header at all unless you set
one explicitly.
from pathlib import Path
await client.post(
"upload",
form={
"note": "shiny",
"avatar": b"raw-bytes-field", # a field, not a file (no filename)
"manual": Path("pikachu-manual.pdf"), # a file, filename + content-type inferred
"photo": ("photo.png", b"...png-bytes..."), # a file, explicit filename, mime inferred
"scan": ("scan.bin", b"...bytes...", "application/pdf"), # explicit content-type override
"tags": ["shiny", "starter"], # one part, JSON-encoded array body
"photos": (("a.png", b"..."), ("b.png", b"...")), # two parts, both named "photos"
},
)
DELETE¶
response = await client.delete("items/7", response_data_type=ItemModel)
response.data # ItemModel(...)
A body is rare on a DELETE but allowed (some APIs, like bulk or delete-by-query endpoints, need
one), with the same json/data/form/content options as post:
HEAD¶
Headers-only — no body is ever decoded, so there's no response_data_type:
response = await client.head("items/7")
response.status # 200
response.headers
response.data # always None
It takes the same response_headers_type option as get, via response.typed_headers.
Downloading large bodies — download¶
get()'s default bytes return is fine for the small JSON bodies this library is designed
around, but for something genuinely large (a presigned S3 GET URL, a big export) it costs extra
memory copies internally. download() is a get-shaped verb built to avoid that:
response = await client.download("exports/large-file.csv") # ~2/3 the peak memory of get()
body = response.data # bytes
response = await client.download("exports/large-file.csv", dest="large-file.csv")
response.data # Path("large-file.csv")
Like get(), it returns a Response, so .status, .headers (say, ETag or
Content-Disposition), .request, .http_version and .elapsed are all there. .elapsed
runs until the whole body has been read or written.
With no dest, the body still ends up fully in memory as bytes, just streamed into one buffer
instead of copied several times along the way: measured on a 50MB body, download() peaks at
about 105MB against get()'s 154MB. Pass dest (a str or any path-like) to stream straight to
disk instead — memory then stays O(chunk size) regardless of how large the body is, and .data is
dest as a Path rather than the body. The client's timeout is the longest allowed gap between
chunks here, not a cap on the whole download, so a long download of a big file isn't cut off at
30s; see Streaming → Timeouts.
The file only appears at dest once the whole body has arrived. It's written to a hidden sibling
first and renamed into place on success, so a download that fails partway (a dropped connection,
a cancelled task) never leaves a truncated file behind, and an existing file at dest is left
exactly as it was. Writes happen on the calling thread even for the async client: each chunk
write takes microseconds, and moving them to a thread measured 2.5-3.8x slower. download() only ever does a GET; there's no
json/data/form/content body option and no response_data_type — the response is always raw
bytes, on disk or in memory. Same params/headers/error_for_status as every other verb.