Senior 5 min · March 05, 2026

FastAPI File Uploads and Form Data

Master multipart/form-data in FastAPI.

N
Naren Founder & Principal Engineer

20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.

Follow
Production
production tested
June 10, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • 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
✦ Definition~90s read
What is FastAPI File Uploads and Form Data?

FastAPI File Uploads and Form Data refers to the built-in capabilities of the FastAPI web framework for handling multipart form submissions, including both file uploads and standard form fields. FastAPI leverages Python's type hints and Pydantic models to automatically parse, validate, and document incoming form data, making it straightforward to accept files (e.g., images, documents) alongside text fields like names or descriptions.

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.

The framework uses the UploadFile class for file handling, which provides an asynchronous interface for reading file contents, metadata, and streaming large files without loading them entirely into memory.

This feature is essential for building modern web APIs that require file submission, such as user profile picture uploads, document management systems, or data import endpoints. FastAPI automatically generates OpenAPI documentation for these endpoints, showing the expected form fields and file types.

Developers can combine multiple file uploads, optional fields, and validation constraints using FastAPI's dependency injection system, ensuring robust input handling with minimal boilerplate code.

Under the hood, FastAPI processes form data using Python's python-multipart library, which efficiently parses multipart/form-data requests. The framework handles common edge cases like missing files, incorrect MIME types, or oversized uploads through configurable validation and error responses.

This approach simplifies what would otherwise require manual request parsing, making FastAPI a popular choice for APIs that need to accept both structured form data and binary file uploads in a single request.

Plain-English First

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.

Don't Forget the Form Dependency
If you omit Form() or File() for a parameter in a multipart endpoint, FastAPI will expect it from JSON, not form data, and return a 422 error.
Production Insight
A file-sharing service used 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.
Key Takeaway
Use UploadFile for any file that could exceed a few KB — it streams to disk, keeping memory constant.
Declare every form field with Form() or File() — missing these causes a 422 error.
FastAPI processes form data incrementally, so you can validate and write chunks before the upload completes.
FastAPI File Uploads and Form Data Flow THECODEFORGE.IO FastAPI File Uploads and Form Data Flow From form data parsing to file validation and sanitization Form Data & Upload Parsing FastAPI uses multipart/form-data for files and fields Single File Upload with Validation Use File() and UploadFile; validate size and type Mix Form Fields with Files Combine Form() for fields and File() for uploads Multiple File Uploads List[UploadFile] to accept several files at once File Type & Name Sanitization Validate MIME type; sanitize filename to prevent traversal Optional File Uploads Set default=None to make file field optional ⚠ Never trust filename from Content-Disposition Always sanitize filenames to prevent path traversal attacks THECODEFORGE.IO
thecodeforge.io
FastAPI File Uploads and Form Data Flow
Fastapi File Uploads Forms

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.

io/thecodeforge/files/upload_handler.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
from fastapi import FastAPI, UploadFile, File, HTTPException, status
import shutil
from pathlib import Path

app = FastAPI()
UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)

@app.post('/upload')
async def upload_document(file: UploadFile = File(...)):
    # 1. MIME Type Validation (Don't trust the extension!)
    if file.content_type not in ['application/pdf', 'image/jpeg']:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST, 
            detail="Invalid file type. Only PDF and JPEG are allowed."
        )

    # 2. Size Validation using file object metadata
    # Note: .size is available in newer FastAPI/Starlette versions
    MAX_SIZE = 10 * 1024 * 1024  # 10MB
    real_file_size = 0
    
    # Stream content to disk to avoid memory spikes
    save_path = UPLOAD_DIR / file.filename
    with open(save_path, "wb") as buffer:
        while chunk := await file.read(1024 * 1024):  # Read in 1MB chunks
            real_file_size += len(chunk)
            if real_file_size > MAX_SIZE:
                raise HTTPException(status_code=413, detail="File too large")
            buffer.write(chunk)

    return {
        'filename': file.filename,
        'saved_at': str(save_path),
        'final_size': real_file_size
    }
Output
{"filename": "contract.pdf", "saved_at": "uploads/contract.pdf", "final_size": 1048576}
Production Insight
Chunked reading with a size check prevents OOM, but also avoids slow writes from reading entire file first.
The file.size attribute is only available in Starlette 0.20+ — always implement a manual size check as a fallback.
Rule: Always read in chunks, never await file.read() without arguments.
Key Takeaway
Use UploadFile for streaming, but never trust content_type alone.
Validate file size during streaming, not after.
Chunk size of 1MB balances memory and I/O overhead.

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.

io/thecodeforge/files/profile_form.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from fastapi import FastAPI, UploadFile, File, Form, status
from typing import Annotated

app = FastAPI()

@app.post('/forge/profile-update', status_code=status.HTTP_201_CREATED)
async def update_profile(
    username: Annotated[str, Form(...)],
    bio: Annotated[str, Form(min_length=10)],
    # Optional file: defaults to None if not in the request
    avatar: UploadFile | None = File(None) 
):
    response = {
        "user_id": 1024, 
        "username": username, 
        "bio": bio,
        "avatar_received": False
    }
    
    if avatar:
        # Process avatar (e.g., upload to S3 or resize)
        response["avatar_received"] = True
        response["avatar_name"] = avatar.filename
        
    return response
Output
{"username": "forge_admin", "bio": "Senior Editor at TheCodeForge", "avatar_received": true}
Production Insight
Developers often try to embed JSON inside a form field (e.g., metadata field) — this breaks type safety.
Use separate form fields for each scalar value; for complex structures, serialize to JSON string and parse inside endpoint.
Rule: No Pydantic model for multipart — declare each field explicitly.
Key Takeaway
You cannot mix JSON body and multipart — use Form() for each field.
Optional files: default to None and use UploadFile | None = File(None).
For complex data, encode as JSON string in a form field.
Form or File? How to Choose Between Form and File
IfField is a simple scalar (string, int, bool)
UseUse Form(...) — FastAPI will parse it from the multipart body.
IfField is a file (image, PDF, video)
UseUse UploadFile = File(...) — gives streaming, metadata, and efficient handling.
IfYou need to send a nested JSON object alongside a file
UseSerialize the JSON to a string field using 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.

io/thecodeforge/files/multi_upload.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from fastapi import FastAPI, UploadFile, File, HTTPException

app = FastAPI()
MAX_FILES = 10
MAX_TOTAL_SIZE = 100 * 1024 * 1024  # 100MB

@app.post('/upload-multiple')
async def upload_multiple(files: list[UploadFile] = File(...)):
    if len(files) > MAX_FILES:
        raise HTTPException(400, detail=f"Maximum {MAX_FILES} files allowed")
    
    total_size = 0
    saved_files = []
    
    for file in files:
        # Stream file contents to disk
        content = b''
        while chunk := await file.read(1024 * 1024):
            content += chunk
            total_size += len(chunk)
            if total_size > MAX_TOTAL_SIZE:
                raise HTTPException(413, detail="Total upload size exceeds limit")
        
        with open(f"uploads/{file.filename}", "wb") as f:
            f.write(content)
        saved_files.append(file.filename)
    
    return {"saved_files": saved_files, "total_size": total_size}
Output
{"saved_files": ["photo1.jpg", "photo2.jpg"], "total_size": 5242880}
Memory Risk with Multiple Files
The above code accumulates each file's content in memory (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.
Production Insight
Multiple file endpoints are a prime target for resource exhaustion — attackers send many large files to fill memory or disk.
Always enforce both per-file and total size limits, and cap the number of files.
Use asyncio.gather for concurrent file processing, but watch for I/O contention on disk.
Key Takeaway
Declare list[UploadFile] for multiple files.
Stream each file independently to disk — never accumulate all chunks in one bytes object.
Enforce 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.

io/thecodeforge/files/validate_type.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import magic
from fastapi import UploadFile, HTTPException

ALLOWED_MIME_TYPES = {
    'image/jpeg', 'image/png', 'application/pdf'
}

async def validate_file_type(file: UploadFile):
    # Read first 2048 bytes for magic detection
    chunk = await file.read(2048)
    await file.seek(0)  # Reset stream position
    
    mime = magic.from_buffer(chunk, mime=True)
    if mime not in ALLOWED_MIME_TYPES:
        raise HTTPException(400, detail=f"File type {mime} is not allowed. Only {ALLOWED_MIME_TYPES}")
    return True
File Type Detection: Trust the Bytes, Not the Header
  • 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.
Production Insight
Trusting 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.
Rule: Never rely solely on client-provided content type — always verify with magic bytes.
Key Takeaway
content_type is client-controlled — never trust it for security decisions.
python-magic reads actual file content to determine MIME type.
Always seek(0) after reading magic bytes to not lose the file data.
When to Use Magic Byte Validation
IfHigh-security endpoint (profile photos, document uploads)
UseUse python-magic or manual header check. Must verify before any processing.
IfLow-risk, internal API with trusted clients
UseRely on content_type plus extension check — but still log mismatches for audit.
IfNeed to handle both images and PDFs with different processing
UseUse magic bytes to route to the correct handler (e.g., resize image vs. extract text from PDF).

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.

io/thecodeforge/files/sanitize_filename.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import re
import uuid
from pathlib import Path

def sanitize_filename(original: str) -> str:
    # Remove directory separators and replace spaces
    safe = re.sub(r'[\\/]', '', original)
    safe = re.sub(r'[^a-zA-Z0-9._-]', '_', safe)
    # Truncate to avoid filesystem limits (255 chars typical)
    return safe[:255]

def generate_unique_filename(original: str) -> str:
    ext = Path(original).suffix
    return f"{uuid.uuid4().hex}{ext}"

# Usage:
# original = "../../etc/passwd"
# safe = sanitize_filename(original)  -> "..__etc_passwd" (after replacements)
# unique = generate_unique_filename(original) -> "a1b2c3d4..."
Production Insight
Path traversal is a classic vulnerability; OWASP lists it consistently in Top 10.
Even with sanitization, an attacker could use null bytes or encoding tricks — always use os.path.basename and join with a safe base directory.
Best practice: never use the original filename on disk — store under a UUID and keep the original name in a database or metadata field.
Rule: Always generate a unique name for storage; store the original name separately for display.
Key Takeaway
Never trust user-supplied filenames — they can contain path traversal sequences.
Use UUID-based filenames for storage, map back to original via database.
If you must keep original name, whitelist characters and strip directory separators.

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 request.body(). If you're building an API that accepts files, you're building a multipart consumer — own the stack.

FormDataAnatomy.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// io.thecodeforge — python tutorial

// Simulating what fastapi sees under the hood
from fastapi import FastAPI, Request, File, Form, UploadFile

app = FastAPI()

@app.post("/signup")
async def signup_with_avatar(
    username: str = Form(...),
    avatar: UploadFile = File(...)
):
    # fastapi extracts these from the same multipart body
    # username comes from a text part, avatar from an octet-stream part
    return {"user": username, "file": avatar.filename}

# curl -X POST http://localhost:8000/signup \
#   -F "username=jdoe" \
#   -F "avatar=@photo.jpg"
Output
{"user":"jdoe","file":"photo.jpg"}
Production Trap:
If you use 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.
Key Takeaway
Form data isn't JSON; it's multipart MIME. Install python-multipart and use UploadFile for anything over a few KB.

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 file.read() on a nullable upload, you'll get a 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.

OptionalFileUpload.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// io.thecodeforge — python tutorial

// Optional file with a required form field
from fastapi import FastAPI, File, Form, UploadFile
from typing import Optional

app = FastAPI()

@app.post("/profile")
async def create_profile(
    username: str = Form(...),
    avatar: Optional[UploadFile] = File(None),
    bio: Optional[str] = Form(None)
):
    # Guard before reading
    if avatar is not None:
        content = await avatar.read()
        return {"user": username, "avatar_size": len(content), "bio": bio}
    return {"user": username, "avatar_size": 0, "bio": bio}

# curl -X POST http://localhost:8000/profile \
#   -F "username=jdoe" \
#   -F "bio=DevOps engineer"
Output
{"user":"jdoe","avatar_size":0,"bio":"DevOps engineer"}
Senior Shortcut:
Don't use 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.
Key Takeaway
Use 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 shutil.copyfileobj(). 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 /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.

SaveFile.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// io.thecodeforge — python tutorial

from fastapi import FastAPI, UploadFile, File
from pathlib import Path
import uuid
from shutil import copyfileobj

app = FastAPI()

UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)

@app.post("/upload/")
async def save_upload(file: UploadFile = File(...)):
    safe_name = f"{uuid.uuid4()}_{file.filename}"
    dest = UPLOAD_DIR / safe_name
    with dest.open("wb") as buffer:
        copyfileobj(file.file, buffer)
    return {"saved_to": str(dest)}
Output
{"saved_to": "uploads/550e8400-e29b-41d4-a716-446655440000_report.pdf"}
Production Trap:
Disk is not infinite. On average, a busy server fills 1TB in 4 days with 50MB/s uploads. Add a storage quota and background cleanup jobs before deploying.
Key Takeaway
Always persist uploaded files to a controlled location with unique names and size limits.

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.

AppSize.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// io.thecodeforge — python tutorial

from fastapi import FastAPI

app = FastAPI()

@app.get("/size")
def show_fastapi_size():
    # FastAPI lines of core code: ~2,500
    # Pydantic: ~10,000
    # Starlette: ~15,000
    return {
        "total_deps": 27_500,
        "your_code": "just endpoints",
        "docs_auto": True
    }
Output
{"total_deps": 27500, "your_code": "just endpoints", "docs_auto": true}
Did You Know?
FastAPI's core contributor, Sebastián Ramírez, designed it to match the performance of Go's Gin framework in benchmark tests.
Key Takeaway
FastAPI's triple-stack (Starlette + Pydantic + Type Hints) eliminates boilerplate while delivering async-native speed.

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.

AuthUpload.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// io.thecodeforge — python tutorial

from fastapi import FastAPI, Depends, HTTPException, UploadFile, File
from fastapi.security import OAuth2PasswordBearer

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
app = FastAPI()

async def verify_token(token: str = Depends(oauth2_scheme)):
    if token != "valid-secret":
        raise HTTPException(status_code=401, detail="Invalid token")
    return token

@app.post("/secure-upload/")
async def secure_upload(file: UploadFile = File(...),
                       token: str = Depends(verify_token)):
    return {"file": file.filename, "authenticated": True}
Output
{"file": "report.pdf", "authenticated": true}
Security Critical:
Never accept tokens via multipart form fields — they appear in request logs. Use the Authorization header exclusively.
Key Takeaway
Inject authentication as a dependency before file processing to reject unauthorized uploads early.
● Production incidentPOST-MORTEMseverity: high

OOM Kill in Production: The 10MB File That Brought Down the API

Symptom
The API became unresponsive; kubectl top pods showed memory usage grew linearly with file size. Eventually the pod was OOMKilled and restarted.
Assumption
The team assumed FastAPI's UploadFile would handle large files by streaming them to disk automatically, so no explicit chunked reading was implemented in the endpoint.
Root cause
The endpoint used 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.
Fix
Implemented chunked reading with a max chunk size of 1MB and a cumulative size check. If file exceeds 100MB, reject immediately with a 413 error. Use file.file for streaming writes with shutil.copyfileobj().
Key lesson
  • Never call await file.read() without a size argument in production — you're asking for an OOM.
  • Always enforce a maximum file size upfront, before processing any content.
  • Use file.file (the SpooledTemporaryFile) for streaming writes, not read() for large files.
Production debug guideCommon symptoms and immediate actions when file uploads fail in production5 entries
Symptom · 01
Upload fails with 413 Request Entity Too Large
Fix
Check reverse proxy (nginx, ALB) client_max_body_size or similar setting. FastAPI itself doesn't impose a limit unless you code it, but proxies do.
Symptom · 02
Upload works but file is empty or truncated
Fix
Check that you're not consuming the file twice (e.g., calling read() for validation and then again for saving). Use file.file.seek(0) after first read.
Symptom · 03
File parameter is always None even though file was sent
Fix
Confirm the client is sending the field as multipart/form-data and the field name matches the endpoint parameter. Use curl -F 'file=@/path' to test.
Symptom · 04
Form fields are received as empty strings or missing
Fix
Verify that all form fields are declared with Form(...) (not Body()). Mixing JSON and multipart is not allowed; use separate Form() parameters.
Symptom · 05
Memory usage spikes during multiple concurrent uploads
Fix
Ensure you are streaming files to disk using chunks. Also set FILE_MAX_SIZE and reject early. Use asyncio.Semaphore to limit concurrent uploads.
★ File Upload Quick Debug Cheat SheetFive-command workflow for diagnosing file upload issues in FastAPI
Upload returns 400 Bad Request
Immediate action
Check if client sent correct Content-Type: multipart/form-data
Commands
curl -v -F 'file=@test.pdf' http://localhost:8000/upload
Check server logs: uvicorn log level DEBUG
Fix now
Ensure route parameter is declared as UploadFile = File(...)
File saved but corrupted+
Immediate action
Check if you called `file.read()` before writing; you may have consumed the stream.
Commands
Add debugging: print(f'File size after read: {len(content)}')
Use `await file.seek(0)` before saving
Fix now
Always use a chunked loop that reads and writes without buffering entire file
High memory usage+
Immediate action
Check endpoint for unbounded `await file.read()`
Commands
kubectl top pods (or docker stats)
Add a size limit check: if file.size > MAX_SIZE: raise HTTPException(413)
Fix now
Make endpoint read in chunks of 1MB
bytes vs UploadFile: What to Use and When
AspectbytesUploadFile
Memory usageEntire file loaded into memoryStreamed; spooled to disk if large
Metadata accessNone — need to parse headers separatelyImmediate: filename, content_type, size
Async supportNot native (await file.read() blocks)Fully async; await read/chunk methods
Best forSmall files (<1MB), simple use casesAll production cases, especially large files
ValidationManual chunking requiredBuilt-in chunking via read(chunk_size)

Key takeaways

1
UploadFile is superior to raw bytes as it uses a spooling temporary file, preventing RAM overflow for large assets.
2
File metadata like filename and content_type are accessible immediately without reading the file body.
3
Synchronous vs Asynchronous
file.read() is an awaitable method; however, for truly massive files, use a streaming chunk approach to keep the memory footprint constant.
4
The 'No Pydantic' Rule
When using multipart/form-data, you must declare fields individually using Form() or use a library like python-multipart.
5
Always use a library like 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 patterns
×

Calling `await file.read()` without a size argument

Symptom
Large files cause OOM and application crash. Memory usage spikes linearly with file size.
Fix
Always pass a chunk size: while chunk := await file.read(1024 * 1024):
×

Using `File()` for form fields that are not files (e.g., strings)

Symptom
FastAPI raises a validation error because it expects a file upload for that field.
Fix
Use Form(...) for non-file form fields. Only use File(...) for actual file upload parameters.
×

Saving file directly with client-provided filename

Symptom
Path traversal vulnerability — files can be written outside the intended directory.
Fix
Sanitize filename or generate a UUID-based name. Never join user input directly to a path.
×

Assuming `content_type` is accurate

Symptom
Malicious files with wrong extension bypass validation, leading to security compromise.
Fix
Use python-magic to detect actual MIME type from file bytes.
×

Not resetting file stream position after reading

Symptom
Subsequent await file.read() or file.file.read() returns empty bytes.
Fix
Call await file.seek(0) after any read call that you expect to re-read.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Explain the difference between `bytes` and `UploadFile` in a FastAPI end...
Q02SENIOR
How does the `python-multipart` library interact with FastAPI under the ...
Q03SENIOR
Describe the security risks associated with allowing user-defined filena...
Q04SENIOR
How would you implement a progress bar or status tracker for a large fil...
Q05SENIOR
What is a 'SpooledTemporaryFile', and how does it prevent the 'Out of Me...
Q01 of 05JUNIOR

Explain the difference between `bytes` and `UploadFile` in a FastAPI endpoint. Which one would you use for a 2GB video upload and why?

ANSWER
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.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
How do I save an uploaded file to disk in FastAPI?
02
Why can't I use a Pydantic model for form data?
03
Can I upload multiple files at once?
04
How do I set a maximum file size in FastAPI?
05
What is the difference between `File(...)` and `File(None)`?
N
Naren Founder & Principal Engineer

20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.

Follow
Verified
production tested
June 10, 2026
last updated
1,554
articles · all by Naren
🔥

That's Python Libraries. Mark it forged?

5 min read · try the examples if you haven't

Previous
FastAPI Database Integration with SQLAlchemy
44 / 51 · Python Libraries
Next
FastAPI Middleware — Logging, CORS and Custom Middleware