FastAPI File Uploads and Form Data
Master multipart/form-data in FastAPI.
20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.
- Use UploadFile over bytes for streaming, memory efficiency, and metadata access
- FastAPI parses multipart data into individual Form() parameters — no single Pydantic model for the whole body
- UploadFile wraps a SpooledTemporaryFile: small files stay in memory, large files hit disk automatically
- Always validate content_type and enforce file size before writing to disk
- The biggest production trap: trusting client-provided MIME type — verify with python-magic or inspect file headers
Think of a file upload like mailing a package with a form inside the same envelope: the letter has your name and address, while the box holds the actual item. FastAPI acts as the mailroom that opens the envelope, reads your form, and hands you the box—without making you unseal everything yourself.
Handling file uploads is a core requirement for most production APIs, yet it's where many FastAPI applications leak memory or open security holes. FastAPI's avails UploadFile and Form to stream multipart payloads without buffering entire requests in RAM, but the defaults hide pitfalls around optional files, path traversal, and missing python-multipart that will bite you at scale.
How FastAPI Handles File Uploads and Form Data
FastAPI uses the UploadFile class and Form dependency to parse multipart/form-data requests. Unlike JSON bodies, form data mixes text fields with binary file streams. FastAPI reads the raw request body, splits it by the MIME boundary, and exposes each part as a Python object. UploadFile wraps a SpooledTemporaryFile, meaning small files stay in memory while large ones spill to disk automatically. This avoids loading entire files into RAM, which is critical for handling multiple concurrent uploads. The default threshold is 1 MB — files above that go to disk. Form fields are parsed as simple strings or coerced to declared types. FastAPI does not buffer the entire request before processing; it streams the parts as they arrive. This means you can start validating fields and writing file chunks before the upload finishes. In practice, this gives you O(1) memory per connection for file data, limited only by disk I/O. You must declare form fields with Form(...) and file fields with File(...) — omitting these will cause FastAPI to interpret the parameter as a JSON body, resulting in a 422 validation error.
Form() or File() for a parameter in a multipart endpoint, FastAPI will expect it from JSON, not form data, and return a 422 error.bytes instead of UploadFile for a 50 MB upload endpoint. The server ran out of memory under 10 concurrent uploads, causing OOM kills. Always use UploadFile for files larger than a few KB — it streams to disk and keeps memory constant.UploadFile for any file that could exceed a few KB — it streams to disk, keeping memory constant.Form() or File() — missing these causes a 422 error.Uploading a Single File with Validation
The UploadFile class is a wrapper around a Python SpooledTemporaryFile. This is critical for performance: it keeps small files in RAM for speed but offloads larger files to the temporary directory of your OS to prevent memory exhaustion. In production, always validate the content_type and enforce a maximum file size.
file.size attribute is only available in Starlette 0.20+ — always implement a manual size check as a fallback.await file.read() without arguments.UploadFile for streaming, but never trust content_type alone.Mixing Form Fields with File Uploads
A frequent pain point for developers is trying to send a Pydantic JSON body alongside a file. Due to the way HTTP works, you cannot easily mix application/json and multipart/form-data. Instead, you must declare each form field using Form(). FastAPI will then parse the multipart body and map the keys to your arguments.
metadata field) — this breaks type safety.Form() for each field.None and use UploadFile | None = File(None).Form(...) — FastAPI will parse it from the multipart body.UploadFile = File(...) — gives streaming, metadata, and efficient handling.Form(), then deserialize in the endpoint.Handling Multiple File Uploads
FastAPI natively supports multiple files under the same field name by declaring the parameter as a list of UploadFile. The client sends each file with the same key, and FastAPI collects them into a Python list. This is common for batch uploads, image galleries, or attachment-heavy workflows.
content += chunk) before writing. For large files, this defeats the purpose of streaming. Use proper streaming by writing each chunk directly to a file descriptor instead of accumulating in a bytes object.asyncio.gather for concurrent file processing, but watch for I/O contention on disk.list[UploadFile] for multiple files.MAX_FILES and MAX_TOTAL_SIZE before processing.Validating File Type Beyond Content-Type
The content_type attribute from the client is trivial to spoof. A malicious client can upload a .exe with Content-Type: image/jpeg. To validate file contents, you need to inspect file signatures (magic bytes). Use python-magic which wraps libmagic, or manually check the first few bytes. This is critical for security-sensitive uploads like profile pictures or document scans.
- Magic bytes are the first n bytes of a file — they identify the format regardless of extension or MIME type.
- libmagic (via python-magic) reads these bytes and returns the actual MIME type.
- Always seek back to 0 after reading the magic chunk — you consumed part of the stream.
- For images, you can also use PIL (Pillow) to verify the image can be decoded — this catches truncated or corrupted files.
content_type directly leads to security vulnerabilities. Attackers can upload executable files disguised as images.python-magic adds latency per upload (~5ms). For high-throughput endpoints, cache allowed MIME signatures or use a file extension whitelist as a pre-filter.content_type is client-controlled — never trust it for security decisions.python-magic reads actual file content to determine MIME type.seek(0) after reading magic bytes to not lose the file data.python-magic or manual header check. Must verify before any processing.content_type plus extension check — but still log mismatches for audit.Sanitising Filenames to Prevent Path Traversal
Saving uploaded files with the client-provided filename is dangerous. A malicious user can supply a name like ../../etc/passwd to overwrite system files or create files outside the intended directory. Always sanitize filenames: strip directory separators, use a whitelist of allowed characters, or generate a UUID-based name and store the original in a database.
os.path.basename and join with a safe base directory.What Actually Is "Form Data" — And Why FastAPI Makes You Install python-multipart
Most devs slap pip install python-multipart into their requirements file without knowing why. Here's the truth: HTTP doesn't have a native "file upload" method. When a browser submits a form with a file input, it encodes the payload using multipart/form-data — a MIME-based format that splits the body into discrete parts, each with its own headers.
FastAPI doesn't ship with a multipart parser because it's a significant chunk of code that most APIs never need. python-multipart is a battle-tested C-extension-backed parser that handles boundary detection, streaming, and memory-efficient chunking. Without it, FastAPI's Form() and File() decorators throw a 422 before your handler even runs.
The key insight: every file upload is a form submission with a specific Content-Type. The form fields and files are just different parts in the same multipart envelope. FastAPI's Form() and File() are syntactic sugar that lets you access those parts without manually parsing . If you're building an API that accepts files, you're building a multipart consumer — own the stack.request.body()
file: bytes = File(), FastAPI loads the entire file into memory. For production systems handling files > 10MB, always use UploadFile — it streams to disk lazily and won't kill your worker's RAM.Optional File Uploads — Because Not Every User Has a Profile Picture
Newcomers assume File(...) means mandatory. It doesn't — but the default behavior is confusing. If you set file: UploadFile = None, FastAPI will treat the missing field as an empty string, not an actual None. That's because multipart parsers emit every field present in the Content-Disposition, even when the file part has zero bytes.
The fix is to use Python's typing.Optional or a default value that the parser can recognise as "not sent". The idiomatic pattern is: file: UploadFile = File(None). This tells FastAPI: if the client doesn't send a file part, this parameter should be None, not an empty UploadFile object.
Why does this matter? If you blindly call await on a nullable upload, you'll get a file.read()RuntimeError when the file object is None. Defensive programming pays off here. Always check if file is not None before accessing any UploadFile attributes. The same pattern applies to form fields paired with optional files — mark them with Form(None) to avoid surprise 422s when clients omit them.
file: bytes = File(None) for optional files — bytes defaults to an empty b'' which makes distinguishing "no file sent" from "empty file sent" impossible. Stick to UploadFile and check for None.File(None) with Optional[UploadFile] for optional uploads, and always guard with a null check before reading.📁 Files Saved Where?
FastAPI doesn't auto-save uploaded files. The UploadFile object holds a SpooledTemporaryFile in memory or disk, but that file vanishes after the request ends unless you persist it. Why? FastAPI gives you full control over storage — cloud buckets, custom directories, or encrypted vaults. Always move the file to a permanent location using . Choose a directory outside the static root to prevent direct URL access. Name collisions destroy data; prefix filenames with UUIDs or timestamps. On disk, set restrictive permissions (0o644 for files, 0o755 for directories). Never store files in shutil.copyfileobj()/tmp — OS cleaners delete them. For cloud storage, stream directly to S3 or GCS without touching disk. If local storage is mandatory, use pathlib.Path for cross-platform safety and check available space before writes.
Facts about FastAPI
FastAPI runs on Starlette for ASGI performance and Pydantic for validation — that combination makes it one of the fastest Python web frameworks, rivaling Node.js and Go. Automatic OpenAPI docs eliminate manual API documentation. Type hints are not optional decoration; they drive request validation, serialization, and interactive docs simultaneously. FastAPI supports both sync and async handlers, letting you mix CPU-bound tasks with I/O-bound operations in the same app. Dependency injection isn't an afterthought — it's baked into every endpoint, enabling reusable logic like authentication checks or database sessions. The framework handles concurrent file uploads without blocking the main thread because UploadFile is async-native. Despite its speed, FastAPI's learning curve is shallow if you know Python type hints. Production deployments benefit from built-in CORS, gzip, and HTTPS redirect middleware.
Authentication
Authentication in FastAPI is not built-in — and that's intentional. Why? You pick the scheme: OAuth2, JWT, API keys, or session cookies. FastAPI provides Depends() as the injection point. Create a reusable dependency that extracts and validates tokens before your upload handler runs. For file uploads, authenticate before accepting the file to reject unauthenticated requests early, saving bandwidth. Use OAuth2PasswordBearer for token extraction from headers, then decode JWTs with python-jose. Store secrets in environment variables, not code. For form-data uploads with authentication, send the token in the Authorization header, not inside the multipart form — multipart parsing happens after header checks. Rate-limit authenticated routes separately using middleware. Combine scopes so only authorized roles can upload sensitive files.
OOM Kill in Production: The 10MB File That Brought Down the API
kubectl top pods showed memory usage grew linearly with file size. Eventually the pod was OOMKilled and restarted.UploadFile would handle large files by streaming them to disk automatically, so no explicit chunked reading was implemented in the endpoint.await file.read() without a size limit, reading the entire file into memory before processing. UploadFile's spooling only kicks in if you write to disk via file.file; read() returns all bytes at once.file.file for streaming writes with shutil.copyfileobj().- Never call
awaitwithout a size argument in production — you're asking for an OOM.file.read() - Always enforce a maximum file size upfront, before processing any content.
- Use
file.file(the SpooledTemporaryFile) for streaming writes, notfor large files.read()
client_max_body_size or similar setting. FastAPI itself doesn't impose a limit unless you code it, but proxies do.read() for validation and then again for saving). Use file.file.seek(0) after first read.File parameter is always None even though file was sentmultipart/form-data and the field name matches the endpoint parameter. Use curl -F 'file=@/path' to test.Form(...) (not Body()). Mixing JSON and multipart is not allowed; use separate Form() parameters.FILE_MAX_SIZE and reject early. Use asyncio.Semaphore to limit concurrent uploads.curl -v -F 'file=@test.pdf' http://localhost:8000/uploadCheck server logs: uvicorn log level DEBUGUploadFile = File(...)Key takeaways
Form() or use a library like python-multipart.python-magic or check the file header if you need deep validation of file types beyond the client-provided MIME type.Common mistakes to avoid
5 patternsCalling `await file.read()` without a size argument
while chunk := await file.read(1024 * 1024):Using `File()` for form fields that are not files (e.g., strings)
Form(...) for non-file form fields. Only use File(...) for actual file upload parameters.Saving file directly with client-provided filename
Assuming `content_type` is accurate
python-magic to detect actual MIME type from file bytes.Not resetting file stream position after reading
await file.read() or file.file.read() returns empty bytes.await file.seek(0) after any read call that you expect to re-read.Interview Questions on This Topic
Explain the difference between `bytes` and `UploadFile` in a FastAPI endpoint. Which one would you use for a 2GB video upload and why?
bytes reads the entire file into memory; UploadFile wraps a SpooledTemporaryFile that streams content. For a 2GB video, use UploadFile because it allows chunked reading (e.g., in 1MB blocks), avoiding OOM. Also gives immediate access to filename and content_type without reading the body.Frequently Asked Questions
20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.
That's Python Libraries. Mark it forged?
5 min read · try the examples if you haven't