Home/Learn/FastAPI/Forms & File Uploads — multipart/form-data

Forms & File Uploads — multipart/form-data

Intermediate
Requests & Validation

Form() 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.

Form() for fields, UploadFile for files — same endpoint, one request
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.

Cap while streaming, sniff magic bytes, generate storage names
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 Aria
1

bytes vs UploadFile in FastAPI — what breaks with bytes on a 2 GB upload?

MediumDunzo
2

A user uploads "photo.png" that is actually a PHP script — how does your endpoint catch it?

HardFlipkart
3

Why is trusting the client filename a path-traversal risk? Show the safe pattern.

HardGoogle

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.

Loading discussion…