httpx upload file: post files as multipart/form-data (tested)

Use httpx.post(url, files={"file": open("report.csv", "rb")}); httpx sends multipart/form-data with the boundary set. Use a (filename, f, type) tuple to override, data= for extra fields, and never open the file in text mode.

Upload files with httpx by passing files=; httpx builds a multipart/form-data body and sets the boundary header for you. Measured with httpx 0.28.1 on Python 3.13.5 against a local http.server (srv.py) that parses the multipart body and echoes each part's field name, filename, content type and size. report.csv is a 14-byte file.

1. httpx upload file

import httpx
from srv import start
base = start()

with open("report.csv", "rb") as f:
    r = httpx.post(base + "/upload", files={"file": f})
print(r.json())

Output:

{'content-type': 'multipart/form-data', 'parts': [{'name': 'file', 'filename': 'report.csv', 'type': 'text/csv', 'size': 14}]}

The filename and content type are taken from the file object (report.csv → text/csv). Do not set Content-Type yourself: a hand-written multipart/form-data header lacks the boundary and the server cannot parse the body.

2. httpx post files with filename, content type and form fields

import httpx
from srv import start
base = start()

with open("report.csv", "rb") as f:
    r = httpx.post(base + "/upload",
                   files={"file": ("q3.csv", f, "text/csv")},
                   data={"title": "Q3"})
for p in r.json()["parts"]:
    print(p)

Output:

{'name': 'title', 'filename': None, 'type': 'text/plain', 'size': 2}
{'name': 'file', 'filename': 'q3.csv', 'type': 'text/csv', 'size': 14}

A tuple (filename, file_or_bytes, content_type) overrides the name and type. Extra form fields go in data= and are sent as parts of the same multipart body (the server here reports a part without a declared type as text/plain).

3. Several files under one field name

import httpx
from srv import start
base = start()

files = [("images", ("a.png", b"\x89PNG...", "image/png")),
         ("images", ("b.png", b"\x89PNG....", "image/png"))]
r = httpx.post(base + "/upload", files=files)
for p in r.json()["parts"]:
    print(p)

Output:

{'name': 'images', 'filename': 'a.png', 'type': 'image/png', 'size': 7}
{'name': 'images', 'filename': 'b.png', 'type': 'image/png', 'size': 8}

Use a list of (field, (filename, content, type)) tuples to repeat a field such as images. Content can be bytes instead of an open file.

4. Gotchas: text-mode files and json=

import httpx
from srv import start
base = start()

# text-mode file: what happens?
try:
    with open("report.csv") as f:
        r = httpx.post(base + "/upload", files={"file": f})
    print(r.json())
except TypeError as e:
    print("TypeError:", e)

# json= together with files=
r = httpx.post(base + "/upload", files={"file": b"x"}, json={"a": 1})
print(r.json())

Output:

TypeError: Multipart file uploads must be opened in binary mode, not text mode.
{'content-type': 'multipart/form-data', 'parts': [{'name': 'file', 'filename': 'upload', 'type': 'application/octet-stream', 'size': 1}]}

Open files with "rb": text mode raises TypeError. And json= is silently dropped when files= is given; a bare bytes value is named upload with type application/octet-stream. Send JSON metadata as a data= field (for example data={"meta": json.dumps(meta)}) if the API accepts that.

5. AsyncClient upload

import asyncio, httpx
from srv import start
base = start()

async def main():
    async with httpx.AsyncClient(base_url=base) as client:
        with open("report.csv", "rb") as f:
            r = await client.post("/upload", files={"file": f})
        print(r.json())

asyncio.run(main())

Output:

{'content-type': 'multipart/form-data', 'parts': [{'name': 'file', 'filename': 'report.csv', 'type': 'text/csv', 'size': 14}]}

A regular binary file object works with AsyncClient in this test; the file is read synchronously, which is fine for small files.

Related: httpx post json vs data, httpx headers, httpx stream (download), httpx vs requests differences.

Frequently asked questions

How do I upload a file with httpx?

httpx.post(url, files={'file': open('x.csv', 'rb')}). httpx builds the multipart/form-data body and Content-Type header.

How do I send form fields with a file in httpx?

Pass them in data= alongside files=; they become parts of the same multipart body.

Why does httpx say multipart file uploads must be opened in binary mode?

The file was opened in text mode. Open it with 'rb'.

Can I send json= with files= in httpx?

No. In httpx 0.28.1 json= is silently dropped when files= is given; put the JSON in a data= field instead.

Written with AI assistance. Measured on 2026-10-23 with httpx 0.28.1 on Python 3.13.5 against a local http.server that parses multipart/form-data. Each block was run verbatim and the output pasted unedited. PlainHeaders is our extension; httpx is free.