FastAPI: Fixing 422 Unprocessable Entity on React Image Uploads
This error occurs when Python receives file upload requests with missing or corrupted multipart boundaries caused by manual Content-Type headers in React or Axios. Let the browser set the multipart boundary automatically.
Root Cause Analysis
This error occurs when Python backend endpoints expecting multipart file uploads receive HTTP requests with incorrect Content-Type headers, missing MIME boundaries, or mismatched field keys from frontend applications.
Root Cause 1: Manually Setting Content-Type in React/Axios
When developers write file upload logic in React using Axios or fetch, they often manually set headers: {'Content-Type': 'multipart/form-data'}. Manually specifying this header overrides the browser's native multipart boundary calculation. Without the boundary parameter (such as boundary=----WebKitFormBoundary...), FastAPI and Starlette cannot parse where individual form fields and binary chunks begin or end, resulting in an immediate 422 Unprocessable Entity or 400 Bad Request error.
Root Cause 2: Field Key Name Mismatch
In FastAPI, a file parameter defined as file: UploadFile = File(...) expects the incoming form field key in the FormData object to be named exactly "file". If the React frontend appends the file under a different key name (for example, formData.append('image', selectedFile)), FastAPI fails to find the required "file" field in the request payload and raises a 422 validation error.
Root Cause 3: Sending Files via JSON.stringify
Frontend beginners sometimes place a JavaScript File object inside a plain JSON dictionary and serialize it with JSON.stringify({ file: selectedFile }). Serializing a binary File object into JSON produces an empty {} object, causing FastAPI to reject the payload.
Root Cause 4: Missing File Content-Type Validation
FastAPI endpoints that accept files should validate the MIME type (e.g. file.content_type == 'image/jpeg'). If invalid file extensions or corrupted headers are sent by the client, server-side validation logic should reject them with structured error responses.
Reproduction Code (MCVE)
def validate_multipart_image_request(headers: dict, form_fields: dict):
content_type = headers.get("content-type", "")
# Check if frontend manually overrode content-type without boundary or sent application/json
if content_type == "application/json":
raise ValueError(
"422 Unprocessable Entity: Expected multipart/form-data with file upload, "
"but received 'application/json'. Do not JSON.stringify() files in React."
)
if "multipart/form-data" in content_type and "boundary=" not in content_type:
raise ValueError(
"422 Unprocessable Entity: 'multipart/form-data' header missing boundary delimiter. "
"Do not manually set the Content-Type header in React/Axios."
)
if "file" not in form_fields:
raise ValueError(
"422 Unprocessable Entity: Missing required form field 'file'. "
f"Found keys: {list(form_fields.keys())}"
)
# Simulating Axios upload where Content-Type was manually set without boundary parameter
headers_with_bad_boundary = {"content-type": "multipart/form-data"}
validate_multipart_image_request(headers_with_bad_boundary, {"file": b"raw_image_data"})
Solution 1: Remove Manual Headers in React and Match Key Names
In React/Axios, construct a FormData object, append the file using the exact parameter key expected by FastAPI, and let Axios/Fetch set the headers automatically.
from fastapi import FastAPI, File, UploadFile, HTTPException, status
import io
app = FastAPI()
@app.post("/upload/image")
async def upload_image(file: UploadFile = File(...)):
# Validate MIME content type
allowed_types = ["image/jpeg", "image/png", "image/webp"]
if file.content_type not in allowed_types:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid file type '{file.content_type}'. Allowed: {allowed_types}"
)
content = await file.read()
return {
"filename": file.filename,
"content_type": file.content_type,
"size_bytes": len(content)
}
# Standalone simulation demonstrating correct FormData handling
def process_valid_upload(filename: str, content_type: str, data: bytes):
assert content_type in ["image/jpeg", "image/png", "image/webp"]
return {"filename": filename, "bytes_received": len(data), "status": "uploaded"}
result = process_valid_upload("avatar.png", "image/png", b"\x89PNG\r\n\x1a\n...")
print("Upload verified:", result)
Solution 2: Support Optional Files and Multiple Attachments
Handle optional file uploads gracefully using UploadFile | None = None and manage multiple files with list[UploadFile] = File(...).
from fastapi import FastAPI, File, UploadFile
from typing import List, Optional
app = FastAPI()
@app.post("/upload/multiple")
async def upload_multiple_images(
files: List[UploadFile] = File(...),
thumbnail: Optional[UploadFile] = File(None)
):
results = []
for f in files:
results.append({"filename": f.filename, "type": f.content_type})
return {"total_files": len(files), "files": results, "has_thumbnail": thumbnail is not None}
# Standalone simulation of multi-file metadata processing
simulated_files = [
{"name": "photo1.jpg", "size": 1024},
{"name": "photo2.jpg", "size": 2048}
]
print("Processed", len(simulated_files), "files successfully.")
In modern Axios (v1.0+), passing a FormData object automatically sets Content-Type: multipart/form-data along with the appropriate boundary. Setting headers manually in Axios interceptors or default configurations will break file uploads across the entire frontend application. Check your Axios global instance for default Content-Type: application/json headers that might inadvertently override multipart uploads.
Another common edge case is handling large image uploads. Reading the entire file into memory using await file.read() can exhaust server RAM under high concurrency. For large files (>10MB), stream the chunks directly to disk or cloud storage (e.g., S3/GCS) using while chunk := await file.read(1024 * 1024):.
Contrast 422 Unprocessable Entity with 413 Payload Too Large: 422 indicates that the structure, boundary, or fields are unreadable or missing, whereas 413 indicates that the web server (or Nginx client_max_body_size) rejected the request because the file exceeds maximum allowed upload limits.