httpx download file: stream to disk in chunks (tested)
Use with httpx.stream("GET", url, follow_redirects=True) as r:, call r.raise_for_status(), then write r.iter_bytes() chunks to a file opened with "wb". Without follow_redirects a redirecting link saves a 0-byte file.
To download a file with httpx without loading it all into memory, use httpx.stream() (or client.stream()) and write iter_bytes() chunks to a file opened in "wb" mode. Measured with httpx 0.28.1 on Python 3.13.5 against a local http.server (srv.py) serving a 1 MiB /file.bin, a 302 at /r and a 404 at /missing.
1. httpx download file in chunks
import os, httpx
from srv import start
base = start()
with httpx.stream("GET", base + "/file.bin") as r:
r.raise_for_status()
total = int(r.headers["Content-Length"])
with open("file.bin", "wb") as f:
for chunk in r.iter_bytes(chunk_size=65536):
f.write(chunk)
print("downloaded", r.num_bytes_downloaded, "of", total)
print("on disk:", os.path.getsize("file.bin"))
Output:
downloaded 1048576 of 1048576
on disk: 1048576
r.num_bytes_downloaded counts raw bytes received, which is what you compare with Content-Length for a progress bar.
2. Gotcha: redirects are not followed
import os, httpx
from srv import start
base = start()
# without follow_redirects you save the empty 302 body
with httpx.stream("GET", base + "/r") as r:
with open("a.bin", "wb") as f:
for chunk in r.iter_bytes():
f.write(chunk)
print(r.status_code, os.path.getsize("a.bin"), "bytes")
with httpx.stream("GET", base + "/r", follow_redirects=True) as r:
with open("a.bin", "wb") as f:
for chunk in r.iter_bytes():
f.write(chunk)
print(r.status_code, os.path.getsize("a.bin"), "bytes")
Output:
302 0 bytes
200 1048576 bytes
Without follow_redirects=True the loop happily writes the empty 302 body and you get a 0-byte file. Many download links (GitHub releases, CDNs) redirect, so pass it and call raise_for_status() before opening the output file.
3. Reading the error body of a streamed response
import httpx
from srv import start
base = start()
try:
with httpx.stream("GET", base + "/missing") as r:
r.raise_for_status()
except httpx.HTTPStatusError as e:
print(e.response.status_code)
try:
print(e.response.text)
except httpx.ResponseNotRead as e2:
print("ResponseNotRead:", e2)
Output:
404
ResponseNotRead: Attempted to access streaming response content, without having called `read()`.
Inside stream() the body has not been read, so e.response.text raises ResponseNotRead. Call r.read() before raise_for_status() when the status is an error if you want to log it.
4. AsyncClient download
import asyncio, os, httpx
from srv import start
base = start()
async def main():
async with httpx.AsyncClient() as client:
async with client.stream("GET", base + "/file.bin") as r:
r.raise_for_status()
with open("async.bin", "wb") as f:
async for chunk in r.aiter_bytes(65536):
f.write(chunk)
print("on disk:", os.path.getsize("async.bin"))
asyncio.run(main())
Output:
on disk: 1048576
Related: httpx stream and iter_bytes, httpx follow redirects, httpx raise_for_status, httpx upload file.
Frequently asked questions
How do I download a large file with httpx?
with httpx.stream('GET', url) as r: write each chunk from r.iter_bytes() to a file opened in 'wb' mode. Memory use stays at one chunk.
Why is my httpx download 0 bytes?
The URL redirected and httpx does not follow redirects by default; the 302 body is empty. Pass follow_redirects=True.
How do I show download progress with httpx?
Compare r.num_bytes_downloaded with int(r.headers['Content-Length']) inside the iter_bytes() loop.
Written with AI assistance. Measured on 2026-10-23 with httpx 0.28.1 on Python 3.13.5 against a local http.server serving a 1 MiB file. Each block was run verbatim and the output pasted unedited. PlainHeaders is our extension; httpx is free.