How to Fix 422 Unprocessable Entity Validation Errors in FastAPI Request Bodies
When working with FastAPI, you may encounter 422 Unprocessable Entity errors like this one: {\"detail\":[{\"loc\":[\"body\",\"age\"],\"msg\":\"value is not a valid integer\",\"type\":\"type_error.integer\"}]}. These arise from Pydantic validation failures during POST, PUT, or PATCH requests when the request body doesn’t conform to your model’s expectations—whether due to type mismatches (like string to int), missing required fields, or issues in nested structures. We can address these systematically by first understanding the validation process and then applying targeted fixes.
Root Cause: Pydantic Validation Pipeline
FastAPI relies on Pydantic’s BaseModel—such as in a parameter like user: UserCreate—to validate incoming request data. The process works like this: FastAPI parses the JSON body, Pydantic then validates against the model’s fields, types, and constraints, and returns a 422 if any check fails. This pipeline ensures data integrity before your endpoint logic runs, though it requires models that match client expectations.
| Error Type | Example Trigger | Loc in Detail |
|---|---|---|
| Type Mismatch | "age": "30" → int | [\"body\",\"age\"] |
| Missing Field | Omit email req | [\"body\",\"email\"] |
| Nested Fail | Invalid sub-model | [\"body\",\"address\",\"zip\"] |
| Constraint | email invalid regex | value_error.str.regex |
| Enum Mismatch | "status": \"pending\" not enum | value_error.const |
| Too Long/Short | name > 50 chars | value_error.any_str.max_length |
| List Invalid | [\"a\",\"b\", 3] int list | type_error.list.item |
The response structure is consistent: a detail array containing ValidationError objects, each with loc (field path), msg (error message), and type (error category). This detail helps pinpoint issues precisely.
Debugging with Middleware: Log the Raw Request Body
If you’re unsure what the client is sending, let’s add an HTTP middleware to log the raw request body. This reveals the exact JSON without validation interference.
from fastapi import FastAPI, Request
import json
app = FastAPI()
@app.middleware("http")
async def log_body(request: Request, call_next):
if request.method in ("POST", "PUT", "PATCH"):
body = await request.body()
print("Raw body:", body.decode())
response = await call_next(request)
return response
Let’s test it with a curl request sending invalid data:\n\nbash\ncurl -X POST http://localhost:8000/users/ -H \"Content-Type: application/json\" -d '{\"age\": \"thirty\"}'\n\n\nYou’ll see in the console: Raw body: {"age": "thirty"}. This confirms the type mismatch without model interference.
Allowing Type Coercion and Union Types
Pydantic v2 attempts to coerce compatible types—like strings to int or float—when not in strict mode. Enabling strict mode enforces exact types, which can cause failures on minor client-side formatting differences.
Model:
from pydantic import BaseModel
from typing import Optional, Union
class UserCreate(BaseModel):
name: str
age: Union[int, str] # Accept str, coerce
email: Optional[str] = None
To enable coercion, set strict: False in the model’s config:\n\npython\nclass UserCreate(BaseModel):\n model_config = ConfigDict(strict=False)\n age: int\n\n\nNote the use of ConfigDict in Pydantic v2. This approach trades stricter type safety for flexibility—use it when clients can’t always send perfect types, but validate further if needed.
Handling Missing or Optional Fields
Omitting a required field triggers the error. You can make it optional with a default or Optional type.
class UserCreate(BaseModel):
name: str
age: int
email: str = "" # Default
# or Optional[str] = None
Alias for JSON keys:
class UserCreate(BaseModel):
name: str = Field(..., alias="full_name")
Validating Nested Models and Lists
Invalid sub-obj/list.
Before (fails):
class Address(BaseModel):
street: str
zip: int # "123abc" fails
class UserCreate(BaseModel):
name: str
address: Address # Nested fail loc: ["body", "address", "zip"]
Fix: Validators or coercion.
from pydantic import field_validator
class Address(BaseModel):
street: str
zip: Union[str, int] # Coerce
@field_validator("zip")
def parse_zip(cls, v):
return int(str(v).replace("abc", ""))
Implementing Custom Validators
Complex logic.
from pydantic import BaseModel, field_validator, ValidationError
class UserCreate(BaseModel):
email: str
@field_validator("email")
@classmethod
def validate_email(cls, v):
if "@" not in v or ".com" not in v:
raise ValueError("Invalid email")
return v.lower()
Choosing Between JSON, Form, and Multipart Data
Wrong Content-Type.
# JSON: application/json → Pydantic
@app.post("/users/", response_model=User)
def create_user(user: UserCreate): ...
# Form: Form(...)
from fastapi import Form
@app.post("/users/form")
def create_user_form(name: str = Form(), age: int = Form()): ...
Working with Enums and Constraints
from enum import Enum
from pydantic import Field
class Status(str, Enum):
active = "active"
inactive = "inactive"
class User(BaseModel):
status: Status
name: str = Field(..., min_length=1, max_length=50)
Customizing Error Responses with Global Handlers
Hide raw detail.
from fastapi import FastAPI, HTTPException
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
return JSONResponse(
status_code=422,
content={"detail": "Invalid input. Check types and required fields."}
)
Full Working Example + Tests
main.py:
from fastapi import FastAPI, Depends
from pydantic import BaseModel, field_validator
app = FastAPI()
class UserCreate(BaseModel):
name: str
age: int
email: str = Field(..., pattern=r"[^@]+@[^@]+\.[^@]+")
@field_validator("age")
@classmethod
def age_check(cls, v):
if v < 0 or v > 150:
raise ValueError("Age invalid")
return v
@app.post("/users/")
def create_user(user: UserCreate):
return user
pytest test_main.py:
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_valid():
rv = client.post("/users/", json={"name": "Bob", "age": 30, "email": "bob@example.com"})
assert rv.status_code == 200
assert rv.json()["name"] == "Bob"
def test_bad_age():
rv = client.post("/users/", json={"name": "Bob", "age": "thirty", "email": "bob@example.com"})
assert rv.status_code == 422
Run: pytest → pass. uvicorn main:app --reload
Best Practices for Maintainable Validation\n\nTo build models that stand the test of time, consider these practices:\n\n- Set model_config = ConfigDict(extra='forbid') to reject unknown fields—this catches client-side changes early rather than ignoring them.\n- Leverage FastAPI’s automatic OpenAPI documentation, which exposes your model schemas to help API consumers send correct data.\n- In production, integrate logging tools like Sentry to capture and analyze validation errors, allowing you to refine models based on real usage without exposing internals to clients.\n\nKeep in mind that stricter validation enhances data quality but may increase client friction; balance based on your API’s consumers and error tolerance. Regular testing of edge cases ensures reliability over time.\n\nRelated articles: FastAPI Depends Circular Imports, Pydantic v2 Migration, FastAPI with orjson
Deploy validation-proof FastAPI today.
Sponsored by Durable Programming
Need help maintaining or upgrading your Python application? Durable Programming specializes in keeping Python apps secure, performant, and up-to-date.
Hire Durable Programming