422 Unprocessable Content — API Validation Failures
症状
- API returns 422 with a JSON body listing field-level validation errors: `{"detail": [{"loc": ["body", "email"], "msg": "value is not a valid email"}]}`
- Request is syntactically valid JSON (not a 400) but fails business rule or schema validation
- FastAPI, Pydantic, or DRF returns 422 for type mismatches, missing required fields, or constraint violations (e.g., min_length, regex)
- cURL returns the full error body but the client app silently fails to show the details
- `Content-Type: application/json` is set but the server expected `multipart/form-data`
- Request is syntactically valid JSON (not a 400) but fails business rule or schema validation
- FastAPI, Pydantic, or DRF returns 422 for type mismatches, missing required fields, or constraint violations (e.g., min_length, regex)
- cURL returns the full error body but the client app silently fails to show the details
- `Content-Type: application/json` is set but the server expected `multipart/form-data`
根本原因
- Required field missing from the request body (e.g., sending `{}` instead of `{"email":"..."}`)
- Field type mismatch — sending a string where the API expects an integer or boolean
- Value failing a constraint: string too short, number out of range, invalid enum value
- Wrong `Content-Type` header — body is JSON but server expects form-encoded or multipart
- Sending extra unexpected fields when the API schema is configured with `additionalProperties: false`
诊断
1. **Read the full 422 response body** — it contains the exact field and reason:
```bash
curl -s -X POST https://api.example.com/users/ \
-H 'Content-Type: application/json' \
-d '{"name": "Alice"}' | python3 -m json.tool
# Output:
# {
# "detail": [
# {"loc": ["body", "email"], "msg": "field required", "type": "value_error.missing"}
# ]
# }
```
2. **Compare your request payload against the API schema** (OpenAPI/Swagger docs):
```bash
curl -s https://api.example.com/openapi.json | \
python3 -c "import sys,json; s=json.load(sys.stdin); \
print(json.dumps(s['components']['schemas']['UserCreate'], indent=2))"
```
3. **Verify the `Content-Type` header** is correct for your payload format:
```bash
# For JSON body:
curl -X POST ... -H 'Content-Type: application/json' -d '{...}'
# For form data:
curl -X POST ... -F 'name=Alice' -F '[email protected]'
```
4. **Validate your payload locally** against the schema before sending:
```python
from pydantic import BaseModel, EmailStr, ValidationError
class UserCreate(BaseModel):
name: str
email: EmailStr
try:
user = UserCreate(name='Alice') # missing email
except ValidationError as e:
print(e.errors())
# [{'loc': ('email',), 'msg': 'field required', 'type': 'value_error.missing'}]
```
```bash
curl -s -X POST https://api.example.com/users/ \
-H 'Content-Type: application/json' \
-d '{"name": "Alice"}' | python3 -m json.tool
# Output:
# {
# "detail": [
# {"loc": ["body", "email"], "msg": "field required", "type": "value_error.missing"}
# ]
# }
```
2. **Compare your request payload against the API schema** (OpenAPI/Swagger docs):
```bash
curl -s https://api.example.com/openapi.json | \
python3 -c "import sys,json; s=json.load(sys.stdin); \
print(json.dumps(s['components']['schemas']['UserCreate'], indent=2))"
```
3. **Verify the `Content-Type` header** is correct for your payload format:
```bash
# For JSON body:
curl -X POST ... -H 'Content-Type: application/json' -d '{...}'
# For form data:
curl -X POST ... -F 'name=Alice' -F '[email protected]'
```
4. **Validate your payload locally** against the schema before sending:
```python
from pydantic import BaseModel, EmailStr, ValidationError
class UserCreate(BaseModel):
name: str
email: EmailStr
try:
user = UserCreate(name='Alice') # missing email
except ValidationError as e:
print(e.errors())
# [{'loc': ('email',), 'msg': 'field required', 'type': 'value_error.missing'}]
```
解决
**Fix 1: Add the missing required field** based on the `loc` path in the error:
```python
# Before (causes 422):
payload = {'name': 'Alice'}
# After:
payload = {'name': 'Alice', 'email': '[email protected]'}
response = httpx.post('https://api.example.com/users/', json=payload)
```
**Fix 2: Correct a type mismatch** — send the right data type:
```python
# API expects an integer, not a string
payload = {'user_id': int(user_id_from_form), 'quantity': 3}
```
**Fix 3: Set the correct `Content-Type`** when the API uses form encoding:
```python
import httpx
# Use data= for form-encoded, json= for JSON
response = httpx.post('https://api.example.com/token',
data={'username': 'alice', 'password': 'secret'}) # OAuth2 form
```
**Fix 4: Return a rich 422 response** from your own Django API for easier client debugging:
```python
# views.py (DRF)
from rest_framework import serializers, status
from rest_framework.response import Response
class UserSerializer(serializers.Serializer):
email = serializers.EmailField()
name = serializers.CharField(min_length=2)
def create_user(request):
serializer = UserSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors,
status=status.HTTP_422_UNPROCESSABLE_ENTITY)
...
```
```python
# Before (causes 422):
payload = {'name': 'Alice'}
# After:
payload = {'name': 'Alice', 'email': '[email protected]'}
response = httpx.post('https://api.example.com/users/', json=payload)
```
**Fix 2: Correct a type mismatch** — send the right data type:
```python
# API expects an integer, not a string
payload = {'user_id': int(user_id_from_form), 'quantity': 3}
```
**Fix 3: Set the correct `Content-Type`** when the API uses form encoding:
```python
import httpx
# Use data= for form-encoded, json= for JSON
response = httpx.post('https://api.example.com/token',
data={'username': 'alice', 'password': 'secret'}) # OAuth2 form
```
**Fix 4: Return a rich 422 response** from your own Django API for easier client debugging:
```python
# views.py (DRF)
from rest_framework import serializers, status
from rest_framework.response import Response
class UserSerializer(serializers.Serializer):
email = serializers.EmailField()
name = serializers.CharField(min_length=2)
def create_user(request):
serializer = UserSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors,
status=status.HTTP_422_UNPROCESSABLE_ENTITY)
...
```
预防
- **Always read the full response body** on non-2xx responses — 422 errors contain exact field paths and messages that tell you precisely what to fix
- **Validate on the client before sending** using the same schema (Pydantic, Zod, Joi) to catch mismatches without a round trip
- **Use 422 instead of 400** in your own APIs when the syntax is valid but semantics fail — it signals to clients that re-reading the docs is more useful than retrying
- **Include the RFC 9457 `problem+json` format** in 422 responses: `{"type": "...", "title": "Validation Error", "status": 422, "errors": [...]}` so client error-handling code is consistent across all endpoints
- **Validate on the client before sending** using the same schema (Pydantic, Zod, Joi) to catch mismatches without a round trip
- **Use 422 instead of 400** in your own APIs when the syntax is valid but semantics fail — it signals to clients that re-reading the docs is more useful than retrying
- **Include the RFC 9457 `problem+json` format** in 422 responses: `{"type": "...", "title": "Validation Error", "status": 422, "errors": [...]}` so client error-handling code is consistent across all endpoints