Form Data vs JSON
FastAPI handles form data and JSON differently:
| Aspect | JSON (Body) | Form Data (Form) |
|---|---|---|
| Content-Type | application/json | application/x-www-form-urlencoded or multipart/form-data |
| Data types | Any JSON-serializable type | Strings (files use multipart/form-data) |
| File support | No | Yes (via multipart/form-data) |
| Nested structures | Yes | No (flat key-value) |
JSON Body:
{"name": "Alice", "age": 30, "roles": ["admin"]}
╲│╱
Parsed as a single payload
Form Data:
name=Alice&age=30
╲│╱
Parsed field by field
Simple Form Data with Form()
Use Form() for traditional HTML form submissions:
from fastapi import FastAPI, Form
app = FastAPI()
@app.post("/login")
def login(
username: str = Form(...),
password: str = Form(...),
):
"""Validate credentials from a form submission."""
return {"username": username}
curl -X POST http://localhost:8000/login \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=admin&password=secret123"
Each Form() parameter becomes an individual field in the form data. FastAPI parses them in order.
Important: You must use
Form()explicitly - unlike JSON body parameters, form fields are not inferred from type hints alone. You also cannot mixBody()andForm()in the same handler; use separate handlers ormultipart/form-datawithFile().
File Uploads with UploadFile
For file uploads, use File() with UploadFile:
from fastapi import FastAPI, File, UploadFile
app = FastAPI()
@app.post("/upload")
async def upload_file(file: UploadFile = File(...)):
"""Upload a single file."""
contents = await file.read()
return {
"filename": file.filename,
"content_type": file.content_type,
"size": len(contents),
}
curl -X POST http://localhost:8000/upload \
-F "file=@photo.jpg"
UploadFile Properties
| Property | Type | Description |
|---|---|---|
filename | str | Original filename from the client |
content_type | str | MIME type (e.g., image/jpeg) |
file | SpooledTemporaryFile | Actual file object (read/write) |
UploadFile Methods
| Method | Description |
|---|---|
read(size) | Read bytes (entire file if size omitted) |
write(data) | Write bytes to file |
seek(offset) | Seek to position |
close() | Close the file handle |
Synchronous vs Asynchronous
UploadFile methods are async - use await:
# ✅ Correct
contents = await file.read()
# ❌ Wrong - UploadFile requires await
contents = file.read()
Multiple File Uploads
Accept multiple files with list[UploadFile]:
from typing import list
@app.post("/upload-multiple")
async def upload_multiple(files: list[UploadFile] = File(...)):
"""Upload multiple files at once."""
results = []
for file in files:
contents = await file.read()
results.append({
"filename": file.filename,
"size": len(contents),
})
return {"files": results}
curl -X POST http://localhost:8000/upload-multiple \
-F "files=@photo.jpg" \
-F "files=@document.pdf"
Optional File Upload
@app.post("/upload-optional")
async def upload_optional(file: UploadFile = File(None)):
"""File upload is optional."""
if file is None:
return {"message": "No file uploaded"}
contents = await file.read()
return {"filename": file.filename, "size": len(contents)}
File Validation (Size & Type)
Validate uploaded files by reading their properties and content:
from fastapi import FastAPI, File, UploadFile, HTTPException
app = FastAPI()
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB
ALLOWED_CONTENT_TYPES = {
"image/jpeg",
"image/png",
"image/gif",
"application/pdf",
}
@app.post("/upload-validated")
async def upload_validated(file: UploadFile = File(...)):
"""Upload with size and type validation."""
# Validate content type
if file.content_type not in ALLOWED_CONTENT_TYPES:
raise HTTPException(
status_code=400,
detail=f"File type {file.content_type} not allowed. "
f"Allowed: {', '.join(ALLOWED_CONTENT_TYPES)}",
)
# Validate size
contents = await file.read()
if len(contents) > MAX_FILE_SIZE:
raise HTTPException(
status_code=400,
detail=f"File too large. Max {MAX_FILE_SIZE // (1024*1024)} MB.",
)
return {
"filename": file.filename,
"content_type": file.content_type,
"size": len(contents),
}
File Validation Helper
Encapsulate validation in a reusable function:
from dataclasses import dataclass
@dataclass
class FileValidation:
max_size: int = 5 * 1024 * 1024
allowed_types: set[str] = None
def __post_init__(self):
if self.allowed_types is None:
self.allowed_types = {
"image/jpeg", "image/png", "image/gif",
"application/pdf", "text/plain",
}
def validate(self, file: UploadFile) -> bytes:
if file.content_type not in self.allowed_types:
raise HTTPException(
status_code=400,
detail=f"Type '{file.content_type}' not allowed",
)
contents = file.file.read()
if len(contents) > self.max_size:
raise HTTPException(
status_code=400,
detail=f"File exceeds {self.max_size // (1024*1024)} MB limit",
)
return contents
validator = FileValidation()
@app.post("/documents")
async def upload_document(doc: UploadFile = File(...)):
contents = validator.validate(doc)
return {"size": len(contents), "type": doc.content_type}
Mixing Files and Form Fields
A single endpoint can accept both form fields and file uploads:
from fastapi import Form
from typing import Optional
@app.post("/create-post")
async def create_post(
title: str = Form(...),
content: str = Form(...),
published: bool = Form(False),
cover_image: UploadFile = File(None),
attachments: list[UploadFile] = File(default_factory=list),
):
"""Create a blog post with optional image and attachments."""
post = {
"title": title,
"content": content,
"published": published,
}
if cover_image:
image_data = await cover_image.read()
post["cover_image"] = cover_image.filename
post["cover_image_size"] = len(image_data)
if attachments:
post["attachments"] = []
for att in attachments:
data = await att.read()
post["attachments"].append({
"filename": att.filename,
"size": len(data),
})
return post
curl -X POST http://localhost:8000/create-post \
-F "title=My Post" \
-F "content=Hello World" \
-F "published=true" \
-F "cover_image=@banner.png" \
-F "attachments=@report.pdf"
Mixing Rules
Endpoint Parameters:
┌────────────────────────────────────────────┐
│ title: str = Form(...) ── Form field│
│ content: str = Form(...) ── Form field│
│ published: bool = Form(False)─ Form field │
│ cover_image: UploadFile ── File │
│ attachments: list[UploadFile]─ Files │
└────────────────────────────────────────────┘
│
▼
Content-Type: multipart/form-data
| Parameter Type | Declaration | Media Type |
|---|---|---|
| Form field | str = Form(...) | multipart/form-data (string part) |
| Single file | file: UploadFile = File(...) | multipart/form-data (binary part) |
| Multiple files | files: list[UploadFile] = File(...) | multipart/form-data (multiple binary parts) |
Saving Files to Disk
import aiofiles
from pathlib import Path
UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)
@app.post("/save-file")
async def save_file(file: UploadFile = File(...)):
"""Save an uploaded file to disk."""
file_path = UPLOAD_DIR / file.filename
# Stream to disk in chunks (memory efficient)
async with aiofiles.open(file_path, "wb") as f:
while chunk := await file.read(1024 * 1024): # 1 MB chunks
await f.write(chunk)
return {"saved_to": str(file_path), "size": file_path.stat().st_size}
For aiofiles support, install it:
pip install aiofiles
Key Takeaways
- Use
Form()for individual form fields andFile()withUploadFilefor file uploads UploadFilehas async methods (read,write,seek,close) - alwaysawaitthem- For multiple files, annotate with
list[UploadFile] = File(...) - Validate file type via
file.content_typeand file size by reading the content - Mix form fields and file uploads in the same handler - form fields use
Form(), files useFile() - Stream large files to disk in chunks with
aiofilesinstead of reading the entire file into memory - Use
/upload-validatedpattern withHTTPExceptionfor rejected files - returns proper 4xx status codes - The content type must be
multipart/form-datawhen mixing files and form fields