Skip to content

Fix: FastAPI 422 Unprocessable Entity (validation error)

FixDevs · (Updated: )

Part of:  Python Errors

Quick Answer

How to fix FastAPI 422 Unprocessable Entity error caused by wrong request body format, missing fields, type mismatches, query parameter errors, and Pydantic validation.

The Error

You call a FastAPI endpoint and get:

{
  "detail": [
    {
      "type": "missing",
      "loc": ["body", "name"],
      "msg": "Field required",
      "input": {}
    }
  ]
}

With HTTP status 422 Unprocessable Entity.

Or variations:

{
  "detail": [
    {
      "type": "string_type",
      "loc": ["body", "age"],
      "msg": "Input should be a valid string",
      "input": 25
    }
  ]
}
{
  "detail": [
    {
      "type": "missing",
      "loc": ["query", "page"],
      "msg": "Field required"
    }
  ]
}

FastAPI validated the incoming request data against the expected schema and found errors. The request body, query parameters, or path parameters do not match what the endpoint expects.

Why This Happens

FastAPI uses Pydantic models to validate every incoming request before your route function runs. The validator walks the declared type annotations, coerces simple types where it can (string to int, string to bool, ISO date strings to datetime), and rejects the rest with a structured error. The 422 status code is FastAPI’s signal that the request reached the server intact but failed schema validation — distinct from 400 (malformed JSON), 415 (wrong content type at the framework layer), and 500 (your code crashed).

The 422 response includes:

  • loc — Where the error is: ["body", "field_name"], ["query", "param_name"], or ["path", "param_name"].
  • msg — Human-readable error description.
  • type — Error type code (missing, string_type, int_parsing, value_error, etc.).
  • input — The actual value that was received.

Common causes:

  • Missing required fields. The request body is missing a field the model requires.
  • Wrong data type. Sending a string where an integer is expected, or a list where an object is expected.
  • Wrong Content-Type. Sending form data instead of JSON, or vice versa. FastAPI dispatches body parsing based on the header.
  • Sending data in the wrong place. Query param instead of body, or body instead of path param.
  • Nested model validation. A nested object has invalid fields. The loc path tells you exactly which level failed.
  • Enum or constraint violations. A value outside the allowed range or not in the enum.
  • Truncated request body. A reverse proxy or client cut off the body mid-stream, so required fields appear “missing” even though the client sent them.

Platform and Environment Differences

The same FastAPI route can return 422 in one environment and succeed in another. The behavior depends on which Pydantic version, which Python version, which ASGI server, and which proxy sits in front of your app.

Pydantic v1 vs v2. FastAPI 0.100 (July 2023) switched to Pydantic v2. The error format changed completely. v1 produced fields like loc, msg, type but with different type codes (value_error.missing, type_error.integer). v2 produces shorter codes (missing, int_parsing) and adds the input field. Code that parses the error list — middlewares, custom exception handlers, frontend forms — breaks silently across the upgrade. Pin Pydantic explicitly in requirements.txt so the format never shifts without a version bump.

Python 3.9 vs 3.10+ type syntax. dict[str, int] and list[str] work as runtime annotations only on Python 3.9+. On 3.8 you need from typing import Dict, List. A model that validates on your laptop (3.11) can raise TypeError at import time on a Lambda runtime (3.9), which surfaces as an early 500 — not a 422 — but is often misdiagnosed as a validation problem.

Uvicorn vs Hypercorn vs Gunicorn. All three are ASGI servers, but their request-body handling differs. Uvicorn (default) reads the full body before invoking your handler. Hypercorn supports HTTP/2 and HTTP/3 and streams differently. Gunicorn wraps Uvicorn workers via gunicorn -k uvicorn.workers.UvicornWorker and respects Gunicorn’s --limit-request-line and --limit-request-field_size. Headers larger than the limit are rejected at the worker, not by Pydantic, so debugging client-side serializers requires checking the worker logs as well as the FastAPI response.

Async vs sync routes. A route declared async def runs inside the event loop directly. A def route runs in a thread pool. Both validate identically, but if your custom validator does blocking I/O (calls requests.get from inside @field_validator), the sync version simply slows down while the async version starves the loop. Neither produces a different 422, but the symptom (intermittent timeouts) gets blamed on validation.

Container memory and CPU limits. Kubernetes pods with low memory limits or Lambda functions with 128 MB allocated can fail to allocate the buffer for large request bodies. Uvicorn raises ClientDisconnect mid-read, FastAPI treats the partial body as missing fields, and Pydantic emits missing errors for fields the client did send. Increase the memory limit if your 422 errors correlate with large payloads.

Reverse proxy buffering. Nginx default client_max_body_size is 1 MB. Anything larger is rejected with 413 before reaching FastAPI. Cloudflare free tier caps request bodies at 100 MB. If you see 422 errors that flip to 413/504 occasionally, the proxy is mangling the request, not Pydantic. Cloudflare also injects cf-connecting-ip headers that some custom validators reject.

Browser fetch vs server-side clients. Browsers automatically add Origin, Referer, and cookies. Server-side clients (requests, httpx) do not. If your validator depends on Origin matching a whitelist, the same JSON body succeeds from a browser and fails from curl. The 422 still says “Field required” because the validator never ran far enough to compute a more specific reason.

Fix 1: Send the Correct Request Body

Match your request to the Pydantic model:

Endpoint definition:

from pydantic import BaseModel

class UserCreate(BaseModel):
    name: str
    email: str
    age: int

@app.post("/users/")
async def create_user(user: UserCreate):
    return {"user": user}

Broken — missing required field:

curl -X POST http://localhost:8000/users/ \
  -H "Content-Type: application/json" \
  -d '{"name": "Alice", "email": "[email protected]"}'
# 422: age is required

Fixed — include all required fields:

curl -X POST http://localhost:8000/users/ \
  -H "Content-Type: application/json" \
  -d '{"name": "Alice", "email": "[email protected]", "age": 30}'

Broken — wrong type:

curl -X POST http://localhost:8000/users/ \
  -H "Content-Type: application/json" \
  -d '{"name": "Alice", "email": "[email protected]", "age": "thirty"}'
# 422: age should be a valid integer

Pro Tip: FastAPI auto-generates interactive API docs at /docs (Swagger UI) and /redoc. Open http://localhost:8000/docs to see the exact schema for every endpoint and test requests interactively.

Fix 2: Set the Correct Content-Type

FastAPI expects JSON by default for request bodies:

Broken — missing Content-Type:

curl -X POST http://localhost:8000/users/ \
  -d '{"name": "Alice"}'
# 422: FastAPI can't parse the body as JSON

Fixed — add Content-Type header:

curl -X POST http://localhost:8000/users/ \
  -H "Content-Type: application/json" \
  -d '{"name": "Alice", "email": "[email protected]", "age": 30}'

In JavaScript:

// Wrong — sends form data by default
fetch('/users/', {
  method: 'POST',
  body: JSON.stringify({ name: 'Alice', email: '[email protected]', age: 30 }),
});

// Fixed — set Content-Type
fetch('/users/', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Alice', email: '[email protected]', age: 30 }),
});

For form data, use Form instead of Pydantic models:

from fastapi import Form

@app.post("/login/")
async def login(username: str = Form(), password: str = Form()):
    return {"username": username}
curl -X POST http://localhost:8000/login/ \
  -d "username=alice&password=secret"

Fix 3: Fix Optional Fields

Make fields optional with default values:

from typing import Optional
from pydantic import BaseModel

class UserCreate(BaseModel):
    name: str                          # Required
    email: str                         # Required
    age: Optional[int] = None          # Optional, defaults to None
    bio: str = ""                      # Optional, defaults to empty string
    role: str = "user"                 # Optional with default

Now these all work:

{"name": "Alice", "email": "[email protected]"}
{"name": "Alice", "email": "[email protected]", "age": 30}
{"name": "Alice", "email": "[email protected]", "age": null, "bio": "Hello"}

In Pydantic v2, a field annotated Optional[int] without a default is still required — it just accepts None as a value. Add = None to make it actually optional. This trips up developers migrating from v1, which treated Optional[X] as implicitly defaulting to None.

Fix 4: Fix Query Parameter Errors

Query parameters have the same validation:

@app.get("/users/")
async def list_users(page: int = 1, limit: int = 10):
    return {"page": page, "limit": limit}

Broken — wrong type in query param:

curl "http://localhost:8000/users/?page=abc"
# 422: page should be a valid integer

Fixed:

curl "http://localhost:8000/users/?page=2&limit=20"

Optional query parameters:

from typing import Optional

@app.get("/search/")
async def search(
    q: str,                           # Required query param
    page: int = 1,                    # Optional with default
    category: Optional[str] = None,   # Optional, can be omitted
):
    return {"q": q, "page": page, "category": category}

Fix 5: Fix Path Parameter Errors

@app.get("/users/{user_id}")
async def get_user(user_id: int):
    return {"user_id": user_id}

Broken:

curl http://localhost:8000/users/abc
# 422: user_id should be a valid integer

Fixed:

curl http://localhost:8000/users/123

Use Path for validation:

from fastapi import Path

@app.get("/users/{user_id}")
async def get_user(user_id: int = Path(gt=0, description="User ID")):
    return {"user_id": user_id}

Fix 6: Fix Nested Model Validation

Nested Pydantic models validate recursively:

class Address(BaseModel):
    street: str
    city: str
    zip_code: str

class UserCreate(BaseModel):
    name: str
    address: Address

@app.post("/users/")
async def create_user(user: UserCreate):
    return user

Broken — missing nested field:

{
  "name": "Alice",
  "address": {
    "street": "123 Main St",
    "city": "Springfield"
  }
}
{
  "detail": [
    {
      "loc": ["body", "address", "zip_code"],
      "msg": "Field required"
    }
  ]
}

Fixed:

{
  "name": "Alice",
  "address": {
    "street": "123 Main St",
    "city": "Springfield",
    "zip_code": "62704"
  }
}

The loc path is the most useful debugging tool here. ["body", "items", 3, "price"] means the fourth element of the items list is missing price. Always log the full loc in your custom exception handler so production failures are actionable.

Fix 7: Add Custom Validation

Use Pydantic validators for complex rules:

from pydantic import BaseModel, field_validator, EmailStr

class UserCreate(BaseModel):
    name: str
    email: EmailStr
    age: int

    @field_validator('name')
    @classmethod
    def name_must_not_be_empty(cls, v):
        if not v.strip():
            raise ValueError('Name cannot be empty')
        return v.strip()

    @field_validator('age')
    @classmethod
    def age_must_be_valid(cls, v):
        if v < 0 or v > 150:
            raise ValueError('Age must be between 0 and 150')
        return v

Use Field constraints:

from pydantic import BaseModel, Field

class UserCreate(BaseModel):
    name: str = Field(min_length=1, max_length=100)
    email: str = Field(pattern=r'^[\w.-]+@[\w.-]+\.\w+$')
    age: int = Field(ge=0, le=150)

EmailStr requires the optional email-validator package: pip install 'pydantic[email]'. Without it, import succeeds but the first request validating an email field raises ImportError and returns 500, not 422.

Fix 8: Customize Error Responses

Override the default 422 response format:

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError

app = FastAPI()

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
    errors = []
    for error in exc.errors():
        field = " -> ".join(str(loc) for loc in error["loc"])
        errors.append({
            "field": field,
            "message": error["msg"],
        })
    return JSONResponse(
        status_code=422,
        content={
            "error": "Validation failed",
            "details": errors,
        },
    )

Still Not Working?

Check the /docs endpoint. FastAPI’s auto-generated Swagger UI shows the exact expected schema for every endpoint. Test your request there first.

Check for Pydantic v1 vs v2 differences. FastAPI 0.100+ uses Pydantic v2 by default. Some model definitions changed:

# Pydantic v1
class User(BaseModel):
    class Config:
        orm_mode = True

# Pydantic v2
class User(BaseModel):
    model_config = ConfigDict(from_attributes=True)

Check for file upload issues. File uploads need File and UploadFile:

from fastapi import File, UploadFile

@app.post("/upload/")
async def upload(file: UploadFile = File()):
    return {"filename": file.filename}

Check for truncated bodies behind a proxy. If 422 errors correlate with large payloads, raise client_max_body_size in Nginx, increase the body size cap in your reverse proxy, and bump Gunicorn’s --limit-request-line. Log the full raw body in a middleware temporarily to confirm the server received what the client sent.

Check for charset and encoding. A client posting UTF-16 or Latin-1 without a charset parameter (Content-Type: application/json; charset=utf-8) can produce bodies that decode to garbled strings. Pydantic then rejects them as string_type errors. Force the client to send UTF-8.

Check for double-serialization. Frontend code that calls JSON.stringify twice produces "\"{\\\"name\\\":\\\"Alice\\\"}\"" — a JSON string containing JSON. FastAPI parses the outer string, sees a primitive, and reports every field as missing. Inspect the raw request body before blaming Pydantic.

Check the OpenAPI schema your client generated. If you use OpenAPI codegen to build a TypeScript SDK, regenerate after every model change. A stale SDK happily sends age: "30" (string) while the new server expects age: 30 (int).

For Python async errors, see Fix: Python RuntimeError: no running event loop. For JSON parsing errors, see Fix: Python JSONDecodeError: Expecting value. For Pydantic-specific validation problems, see Fix: Pydantic ValidationError. For CORS issues that masquerade as validation failures, see Fix: CORS preflight request blocked.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles