File Uploads
24 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)A seller wants to add a photo to a product, or a student wants to upload a CSV of marks. JSON cannot carry a file well, so browsers send it as a form. FastAPI reads that form for you with UploadFile.
UploadFile: one file in a request
UploadFile is FastAPI's object for an uploaded file. It has filename, content_type and async methods read, write, seek and close. Large files are kept in a temporary file on disk, not all in memory.
A file arrives as multipart/form-data. Declare a parameter of type UploadFile and FastAPI gives you an object with the file name, the content type and methods to read the bytes.
from fastapi import FastAPI, UploadFile
app = FastAPI()
@app.post("/upload")async def upload(file: UploadFile): data = await file.read() return {"name": file.filename, "size": len(data)}Example 1 · Upload a CSV and read it
from fastapi import FastAPI, File, Form, HTTPException, UploadFilefrom fastapi.testclient import TestClient
app = FastAPI()
@app.post("/upload")async def upload(file: UploadFile): data = await file.read() return {"name": file.filename, "type": file.content_type, "size": len(data)}
client = TestClient(app)res = client.post("/upload", files={"file": ("sales.csv", b"id,total\n1,50\n2,70\n", "text/csv")})print(res.status_code, res.json())print(client.post("/upload").status_code)200 {'name': 'sales.csv', 'type': 'text/csv', 'size': 19}
422What this code does
files=builds a real multipart request, soUploadFilegets the name, type and bytes.- Without the file, FastAPI answers
422before the route runs.
file.read() is async, so the route is async def and the call is awaited. The python-multipart package must be installed for form parsing.
Form fields next to the file
multipart/form-data is the request format for forms that carry files. The body is split into parts, and each part has a field name, optional file name and its own content type.
A request can carry text fields and a file together. Text fields are declared with Form(...). A request with a file body is a form, so you cannot also declare a JSON body in the same route.
from fastapi import FastAPI, File, Form, UploadFile
app = FastAPI()
@app.post("/products/{product_id}/image")async def set_image(product_id: int, caption: str = Form(...), image: UploadFile = File(...)): data = await image.read() return {"product": product_id, "caption": caption, "bytes": len(data)}Example 2 · A form field and a file together
from fastapi import FastAPI, File, Form, HTTPException, UploadFilefrom fastapi.testclient import TestClient
app = FastAPI()
@app.post("/products/{product_id}/image")async def set_image(product_id: int, caption: str = Form(...), image: UploadFile = File(...)): data = await image.read() return {"product": product_id, "caption": caption, "bytes": len(data)}
client = TestClient(app)res = client.post( "/products/7/image", data={"caption": "Front view"}, files={"image": ("front.png", b"12345678", "image/png")},)print(res.status_code, res.json())res = client.post("/products/7/image", files={"image": ("front.png", b"1234", "image/png")})print(res.status_code, res.json()["detail"][0]["loc"])200 {'product': 7, 'caption': 'Front view', 'bytes': 8}
422 ['body', 'caption']What this code does
data=holds the text fields andfiles=holds the file. Both travel in one multipart body.- The second call forgets
caption. The error says where: in thebody, in the fieldcaption.
product_id comes from the path, caption from a form field, image from the file part. A missing part gives a 422 answer.
Never trust the upload
Magic bytes are the first few bytes of a file that identify its format. A PNG image always starts with the same 8 bytes, whatever its name says.
The file name and content type are written by the client, so they can lie. Check the size, check the type from an allow-list, and check the first bytes of the file when it matters. Answer 413 for too large and 415 for a wrong type.
PNG = b"\x89PNG\r\n\x1a\n"MAX_BYTES = 1_000_000
async def read_png(file: UploadFile) -> bytes: data = await file.read(MAX_BYTES + 1) if len(data) > MAX_BYTES: raise HTTPException(413, "File too large") if not data.startswith(PNG): raise HTTPException(415, "Not a PNG image") return dataExample 3 · Check type, size and first bytes
from fastapi import FastAPI, File, Form, HTTPException, UploadFilefrom fastapi.testclient import TestClient
PNG = b"\x89PNG\r\n\x1a\n"MAX_BYTES = 32ALLOWED = {"image/png"}app = FastAPI()
@app.post("/photos", status_code=201)async def upload_photo(file: UploadFile): if file.content_type not in ALLOWED: raise HTTPException(status_code=415, detail="Only PNG images are allowed") data = await file.read(MAX_BYTES + 1) if len(data) > MAX_BYTES: raise HTTPException(status_code=413, detail="File too large") if not data.startswith(PNG): raise HTTPException(status_code=415, detail="Content is not a PNG") return {"name": file.filename, "size": len(data)}
client = TestClient(app)def send(name, content, ctype): res = client.post("/photos", files={"file": (name, content, ctype)}) print(res.status_code, res.json())
send("ok.png", PNG + b"x" * 10, "image/png")send("big.png", PNG + b"x" * 40, "image/png")send("notes.txt", b"hello", "text/plain")send("fake.png", b"hello", "image/png")201 {'name': 'ok.png', 'size': 18}
413 {'detail': 'File too large'}
415 {'detail': 'Only PNG images are allowed'}
415 {'detail': 'Content is not a PNG'}What this code does
- The four checks run in a fixed order: declared type, size, then real content.
- The last call claims to be a PNG but its bytes say otherwise, so only the magic-byte check stops it.
Reading MAX_BYTES + 1 bytes is enough to know the file is too big, without reading all of it. Also set a body limit in the web server in front, because the upload has already arrived by then.
Save safely and test with files=
Path traversal is an attack where a file name such as ../../config.py makes the server write outside its upload folder. Using only your own generated names prevents it.
Never build a path from the client's file name: ../../app.py could overwrite your code. Pick your own name and keep only a safe extension. In tests, pass files= to TestClient.
from pathlib import PurePosixPath
def safe_name(filename: str) -> str: return PurePosixPath(filename.replace("\\", "/")).name
client = TestClient(app)res = client.post( "/upload", files={"file": ("sales.csv", b"id,total\n1,50\n", "text/csv")},)Example 4 · Save under a safe name
from fastapi import FastAPI, File, Form, HTTPException, UploadFilefrom fastapi.testclient import TestClient
import osimport tempfilefrom pathlib import Path, PurePosixPath
EXTENSIONS = {"image/png": ".png", "image/jpeg": ".jpg"}
def safe_name(filename: str) -> str: return PurePosixPath(filename.replace("\\", "/")).name
with tempfile.TemporaryDirectory() as folder: app = FastAPI()
@app.post("/products/{product_id}/image", status_code=201) async def save_image(product_id: int, image: UploadFile): extension = EXTENSIONS.get(image.content_type) if extension is None: raise HTTPException(status_code=415, detail="PNG or JPEG only") stored = f"product-{product_id}{extension}" (Path(folder) / stored).write_bytes(await image.read()) return {"stored_as": stored, "client_name": safe_name(image.filename)}
client = TestClient(app) res = client.post("/products/3/image", files={"image": ("../../etc/passwd.png", b"abc", "image/png")}) print(res.status_code, res.json()) print(os.listdir(folder)) print(client.post("/products/3/image", files={"image": ("run.exe", b"abc", "application/octet-stream")}).status_code)201 {'stored_as': 'product-3.png', 'client_name': 'passwd.png'}
['product-3.png']
415What this code does
- The stored name is built from our own product id and an extension chosen from the checked type. The client's name is never used as a path.
safe_namekeeps only the last part, so it is safe to show or log.
Example 5 · Many files and an optional file
from fastapi import FastAPI, File, Form, HTTPException, UploadFilefrom fastapi.testclient import TestClient
app = FastAPI()
@app.post("/documents")async def add_documents(title: str = Form(...), files: list[UploadFile] = File(...), cover: UploadFile | None = None): sizes = {f.filename: len(await f.read()) for f in files} return {"title": title, "sizes": sizes, "cover": cover.filename if cover else None}
client = TestClient(app)parts = [("files", ("a.txt", b"aa", "text/plain")), ("files", ("b.txt", b"bbbb", "text/plain"))]print(client.post("/documents", data={"title": "Notes"}, files=parts).json())parts.append(("cover", ("cover.png", b"x", "image/png")))print(client.post("/documents", data={"title": "Notes"}, files=parts).json()){'title': 'Notes', 'sizes': {'a.txt': 2, 'b.txt': 4}, 'cover': None}
{'title': 'Notes', 'sizes': {'a.txt': 2, 'b.txt': 4}, 'cover': 'cover.png'}What this code does
list[UploadFile]collects every part sent with the same field name.UploadFile | None = Nonemakes a file optional.
Each entry in files= is (file name, content bytes, content type). Extra form fields go in data={...}.
Next step
Check your understanding
12 quick questions