Forms & File Uploads — multipart/form-data
IntermediateForm() reads HTML-form fields, File()/UploadFile handle uploads — UploadFile streams to a spooled temp file so big files don't eat RAM. Validate content type and size yourself; the framework won't.
Overview
Not everything is JSON. Login forms post application/x-www-form-urlencoded; anything with a file is multipart/form-data — and FastAPI handles both through Form() and File()/UploadFile (install python-multipart). The critical distinction is bytes vs UploadFile: bytes loads the whole upload into memory, while UploadFile spools to a temporary file and exposes an async read/seek interface plus filename and content_type — always prefer it for real uploads. FastAPI validates that a file arrived, but not what it is: enforcing size limits, checking magic bytes rather than trusting the client's content type, and generating your own storage filename are your job, and every one of those is a classic security interview question. Form fields and files can mix in one endpoint, mirroring a real "upload marksheet with remarks" form.
Form Fields and UploadFile
Form() parameters bind to form fields instead of JSON; UploadFile gives filename, content_type, and async file operations. Mixing Form + File in one signature handles the standard "fields + attachment" page.
from fastapi import FastAPI, File, Form, UploadFile
app = FastAPI()
# pip install python-multipart ← required for forms & uploads
@app.post("/login") # classic form post (no file)
def login(username: str = Form(), password: str = Form()):
return {"user": username} # OAuth2 password flow uses exactly this
@app.post("/marksheets") # fields + file in ONE request
async def upload_marksheet(
student_id: int = Form(),
semester: int = Form(ge=1, le=8),
remarks: str = Form(default=""),
sheet: UploadFile = File(),
):
header = await sheet.read(5) # first bytes (magic number)
await sheet.seek(0) # rewind before the real read
content = await sheet.read()
return {
"student": student_id,
"semester": semester,
"filename": sheet.filename, # client-supplied — do NOT trust
"declared_type": sheet.content_type, # also client-supplied
"size_bytes": len(content),
"is_pdf": header == b"%PDF-", # verified from actual bytes
}
# bytes vs UploadFile:
# photo: bytes = File() → whole file in RAM (fine for tiny files only)
# photo: UploadFile → spooled temp file, async streaming — DEFAULT
# Multiple files: photos: list[UploadFile] = File()Upload Safety — Size, Type, and Names
Trust nothing from the client: cap size while streaming (not after loading), verify magic bytes not the declared content type, and never use the client filename for storage — generate your own.
import uuid
from pathlib import Path as FsPath
from fastapi import FastAPI, HTTPException, UploadFile, File
app = FastAPI()
MAX_BYTES = 5 * 1024 * 1024 # 5 MB
MAGIC = {b"%PDF-": ".pdf", b"\x89PNG\r\n": ".png", b"\xff\xd8\xff": ".jpg"}
UPLOAD_DIR = FsPath("uploads")
@app.post("/documents")
async def upload_document(doc: UploadFile = File()):
# 1. Size cap — stream in chunks, abort early; never read() then check
size, chunks = 0, []
while chunk := await doc.read(64 * 1024):
size += len(chunk)
if size > MAX_BYTES:
raise HTTPException(413, "File exceeds 5 MB limit")
chunks.append(chunk)
content = b"".join(chunks)
# 2. Real type from magic bytes — content_type header is client-controlled
ext = next((e for m, e in MAGIC.items() if content.startswith(m)), None)
if not ext:
raise HTTPException(415, "Only PDF, PNG or JPG allowed")
# 3. NEVER trust doc.filename ("../../etc/cron.d/x" is a path traversal
# attack; "resume.php" targets misconfigured servers). Generate a name:
safe_name = f"{uuid.uuid4().hex}{ext}"
(UPLOAD_DIR / safe_name).write_bytes(content)
return {"stored_as": safe_name, "size": size}
# Production notes: put a hard cap at the proxy too (nginx client_max_body_size);
# store in S3/GCS, not the app disk; serve back via signed URLs, never by
# reflecting the original filename.Key Points to Remember
- 1python-multipart required; Form() = form fields, File()/UploadFile = uploads
- 2UploadFile spools to disk with async read/seek — prefer it over bytes
- 3filename and content_type come from the client — verify magic bytes yourself
- 4Cap size while streaming; store under generated names, never client names
Interview Questions
Sign in to ask Ariabytes vs UploadFile in FastAPI — what breaks with bytes on a 2 GB upload?
A user uploads "photo.png" that is actually a PHP script — how does your endpoint catch it?
Why is trusting the client filename a path-traversal risk? Show the safe pattern.
Ask Aria about Forms & File Uploads — multipart/form-data
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.