Skip to main content

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

PitfallCause / SymptomFix
File empty after first readfile.read() called twiceRead once, store in variable
Large file in memoryawait file.read() for multi-GB fileUse shutil.copyfileobj for streaming
No type validationMalicious uploads with wrong contentCheck file.content_type and file magic bytes
Filename path traversal../../../etc/passwd as filenameUse Path(file.filename).name (basename only)
MIME type spoofedContent-Type header can be fakedValidate 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

What's Next