Streaming¶
stream_get/stream_post stream the response instead of buffering the whole body in memory —
useful for large downloads, or a feed that keeps producing data over one long-lived connection.
Like sse(), they're async generators on HTTPClient and plain generators on
SyncHTTPClient. If you stop reading early, close the stream to release its connection straight
away, rather than whenever the generator is garbage-collected:
from contextlib import aclosing
async with aclosing(client.stream_get("feed")) as chunks:
async for chunk in chunks:
if done(chunk):
break # the connection closes here
On SyncHTTPClient, use contextlib.closing or call chunks.close().
Raw chunks (default)¶
Without a decode target, chunks are yielded exactly as received off the wire — no buffering, no newline splitting:
This is the safe default for arbitrary binary content, including bytes that happen to contain a
literal \n — nothing here ever inspects or splits the chunk boundaries.
NDJSON (response_data_type)¶
Pass response_data_type to switch to newline-buffered decoding instead: chunks are
buffered internally and split on \n, and each complete line is parsed and decoded as it
arrives:
Warning
Same parameter name as every other verb, but a different meaning here: on get/post/etc.
it decodes the whole response body as one value; on stream_get/stream_post it decodes
each line of an NDJSON stream as a separate value. The name was standardized for
consistency across the API — keep this per-line-vs-whole-body distinction in mind when
reading a call site.
async for item in client.stream_get("stream/items", response_data_type=ItemModel):
print(item) # ItemModel(...), one per NDJSON line
Same decode targets as everywhere else — a pydantic BaseModel, a msgspec Struct,
plain dict, or a pydantic TypeAdapter/msgspec Decoder for a discriminated union (see
SSE for the equivalent pattern). A trailing line with no final \n is still decoded
once the connection closes.
Warning
The buffering is strictly conditional on response_data_type being passed — without
it, chunks are never split on \n. Passing binary data through the raw path is always safe;
it's only the NDJSON path that assumes line-delimited text.
Streaming a request with a body — stream_post¶
stream_post takes the same json/data/form/content body options as post (at most one of
them), so you can stream the response to a request that itself has a body — e.g. streaming
back the results of a search:
async for item in client.stream_post(
"stream/search", json={"q": "pikachu"}, response_data_type=ItemModel
):
print(item)
The raw-chunks default applies here too — omit response_data_type to get unbuffered
bytes back from a stream_post call.
Timeouts¶
For stream_get, stream_post and download, the client's timeout is the longest the client
will wait between chunks, not a cap on the whole transfer. A multi-hour download or a feed
that keeps producing data works with the default 30s, and a server that goes silent for 30s (or
never sends response headers at all) fails with HTTPTimeoutError. Time your own code spends
between chunks isn't counted: the clock only runs while waiting on the server.
async with HTTPClient(base_url="https://api.example.com/", timeout=30) as client:
await client.download("exports/huge.csv", dest="huge.csv") # fine at 2 hours, if data flows
- A per-call
timeout=puts back a hard cap on that one call's whole transfer. - Setting
read_timeouton the client replaces this gap limit with pyreqwest's own. sse()has no gap limit by default, since quiet periods are normal on an event stream; setread_timeoutif you want one there.
On the sync client, waiting with a limit means reading on a worker thread, since a blocking read can't otherwise be given up on. That costs no measurable throughput. The one cost is on a genuine stall: after the timeout fires, the stuck read's thread and socket stay parked until the server closes the connection or the process exits (see the warning below).
Errors¶
error_for_status (default True) is checked once, before the first chunk is yielded — a
4xx/5xx response raises HTTPResponseError immediately rather than partway through the stream. See
Error handling.
Ctrl-C-interruptible streaming (sync only) — interruptible¶
On SyncHTTPClient only, a blocking wait for the next chunk is dead to Ctrl-C: CPython only
converts SIGINT into KeyboardInterrupt on the main thread while it's executing Python
bytecode, and the wait between chunks happens inside a blocking Rust call with no bytecode
running at all. The async client doesn't have this problem — the event loop's selector is
already signal-interruptible — so interruptible only exists on stream_get/stream_post on
SyncHTTPClient (and on sse(), see SSE).
Pass interruptible=True to fix this: the read loop runs on a daemon worker thread instead,
and the calling thread only ever does short, signal-interruptible waits on a queue, so Ctrl-C
fires within roughly 0.2 seconds instead of never:
for chunk in sync_client.stream_get("download/large-file", interruptible=True):
handle_chunk(chunk) # Ctrl-C now works while waiting for the next chunk
A sync stream stuck in a stalled read leaves a thread and a socket parked
Sync streams with a time limit (every stream_get/stream_post/download by default, see
Timeouts above) or interruptible=True read on a worker thread, and there is no cancellation
path for a read that's already in flight: dropping or exiting a streamed response does
not cancel it, it blocks until that read resolves one way or another.
While data is still arriving that's harmless: stop early (a break, or the generator being
garbage-collected) and the worker notices at its next chunk, stops, and releases the
connection. It hands chunks over a bounded queue, so it never reads far ahead of a slow
consumer either. But a worker parked in a read that never returns (the server has stalled,
whether a timeout then fired or you stopped iterating) keeps its thread and open socket until
the peer closes the connection; process exit is what actually reclaims it.
Ctrl-C itself is rarely the exposure here: a long-lived process has no controlling terminal to receive it from, and when it does, SIGINT there is usually aimed at killing the whole process anyway, which reclaims the leak along with everything else. The real risk is code that repeatedly abandons streams against a server that stalls while the process stays up.