File Uploads
FastAPI uses UploadFile for streaming file uploads. Files are received in chunks — they don't need to be loaded entirely into memory before processing, which is critical for large files.
Learning Focus
By the end of this lesson you can: accept single and multiple file uploads, validate file type and size, save files to disk, and return appropriate error messages for invalid uploads.
Single File Upload
app/routers/uploads.py
from fastapi import APIRouter, UploadFile, File, HTTPException
from pathlib import Path
import shutil
UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)
router = APIRouter(prefix="/uploads", tags=["uploads"])
@router.post("/", status_code=201)
async def upload_file(file: UploadFile = File(...)) -> dict:
file_path = UPLOAD_DIR / file.filename
with file_path.open("wb") as dest:
shutil.copyfileobj(file.file, dest)
return {
"filename": file.filename,
"content_type": file.content_type,
"size_bytes": file_path.stat().st_size,
}
Multiple File Uploads
app/routers/uploads.py
from typing import Annotated
@router.post("/multiple/", status_code=201)
async def upload_multiple(
files: Annotated[list[UploadFile], File(description="Multiple files")]
) -> dict:
saved = []
for f in files:
path = UPLOAD_DIR / f.filename
with path.open("wb") as dest:
shutil.copyfileobj(f.file, dest)
saved.append({"filename": f.filename, "type": f.content_type})
return {"uploaded": len(saved), "files": saved}
Validating File Type and Size
app/routers/images.py
import io
from fastapi import APIRouter, UploadFile, File, HTTPException
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp"}
MAX_FILE_SIZE_MB = 10
MAX_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
router = APIRouter(prefix="/images", tags=["images"])
@router.post("/", status_code=201)
async def upload_image(file: UploadFile = File(...)) -> dict:
if file.content_type not in ALLOWED_IMAGE_TYPES:
raise HTTPException(
415,
detail=f"Unsupported type: {file.content_type}. Allowed: {ALLOWED_IMAGE_TYPES}",
)
contents = await file.read()
if len(contents) > MAX_BYTES:
raise HTTPException(
413,
detail=f"File too large. Maximum {MAX_FILE_SIZE_MB}MB allowed.",
)
# Save the validated file
path = UPLOAD_DIR / file.filename
with path.open("wb") as f:
f.write(contents)
return {"filename": file.filename, "size": len(contents)}
Mixed Form Data and Files
app/routers/uploads.py
from fastapi import Form
@router.post("/with-metadata/")
async def upload_with_metadata(
file: UploadFile = File(...),
title: str = Form(...),
description: str = Form(""),
) -> dict:
contents = await file.read()
return {
"title": title,
"description": description,
"filename": file.filename,
"size": len(contents),
}
note
Form(...) and UploadFile cannot be used together with a JSON body. When using file uploads, the request must be multipart/form-data, not application/json.
Saving to Object Storage (S3)
app/services/storage.py
import boto3
from botocore.exceptions import ClientError
from fastapi import UploadFile
s3 = boto3.client("s3", region_name="us-east-1")
async def upload_to_s3(file: UploadFile, bucket: str, key: str) -> str:
contents = await file.read()
try:
s3.put_object(
Bucket=bucket,
Key=key,
Body=contents,
ContentType=file.content_type,
)
except ClientError as e:
raise RuntimeError(f"S3 upload failed: {e}")
return f"https://{bucket}.s3.amazonaws.com/{key}"
Common Pitfalls
| Pitfall | Cause / Symptom | Fix |
|---|---|---|
| File empty after first read | file.read() called twice | Read once, store in variable |
| Large file in memory | await file.read() for multi-GB file | Use shutil.copyfileobj for streaming |
| No type validation | Malicious uploads with wrong content | Check file.content_type and file magic bytes |
| Filename path traversal | ../../../etc/passwd as filename | Use Path(file.filename).name (basename only) |
| MIME type spoofed | Content-Type header can be faked | Validate actual file bytes (e.g., with python-magic) |
Hands-On Practice
test-upload.sh
uvicorn app.routers.images:router --reload --port 8009
# Upload a valid image
curl -X POST http://localhost:8009/images/ \
-F "file=@/path/to/photo.jpg"
# Upload wrong type
curl -X POST http://localhost:8009/images/ \
-F "file=@/path/to/document.pdf" \
-w "\nHTTP %{http_code}"
# → 415
# Upload oversized file (create a 15MB file to test)
dd if=/dev/urandom bs=1M count=15 of=/tmp/large.jpg
curl -X POST http://localhost:8009/images/ \
-F "file=@/tmp/large.jpg" \
-w "\nHTTP %{http_code}"
# → 413