FULL STACK CITY

The Gatehouse · 5 min read

Request validation in FastAPI with Pydantic: why bad input gets a 422

Why you never trust a request, describing input with a Pydantic model, adding limits with Field(), and reading the 422 response FastAPI sends when validation fails.

Never trust the request

Byte: Everything that reaches your server was written by a stranger.

internetgateyour server

On the frontend you validate forms with required, minLength or a Zod schema. That protects honest users from typos. It does not protect your server.

Anyone can skip your form and send a request with curl, Postman or DevTools. The server only ever sees the raw request, so the server must check every field again, every time.

In the Backend District that checkpoint is the Gatehouse. Good requests get in (2xx), bad ones bounce (4xx) before they touch your code.

Quick check: Your signup form has min=13 on the age input. Is the server safe from a 5-year-old signing up?
  • Yes, the browser blocks it
  • No, anyone can send the request without the form

Right. Browser checks are a convenience for users. The server has to validate the request itself.

Describe the request with a model

Byte: A Pydantic model is a guest list for the fields you accept.

In FastAPI you describe the JSON body as a class that extends BaseModel. Each annotated attribute is a field the request must contain, with a type.

When a request arrives, FastAPI parses the JSON into that model before your handler runs. If a field is missing or has the wrong type, FastAPI answers 422 for you and your handler never runs.

signup.py
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class SignupRequest(BaseModel):
username: str
age: int
@app.post("/signup", status_code=201)
async def signup(body: SignupRequest):
return {"welcome": body.username}
Quick check: With the model above, which request bounces with 422?
  • {"username": "neo", "age": 25}
  • {"username": "neo", "age": "old"}
  • {"username": "neo", "age": 5}

"old" is not an integer, so it bounces. But age 5 gets in: the model only checks the type, not the range. That is the hole you will plug next.

Add limits with Field()

Byte: Types check the shape. Field() checks the values.

Field() adds constraints to a field. Numbers get range limits, strings get length limits. Limits named ge and le are inclusive: the boundary value itself is allowed.

signup.py
from pydantic import BaseModel, Field
class SignupRequest(BaseModel):
username: str = Field(min_length=3, max_length=12)
age: int = Field(ge=13, le=120)
ConstraintMeans
ge=13greater than or equal to 13 (13 is allowed)
gt=13strictly greater than 13 (13 bounces)
le=120less than or equal to 120
lt=120strictly less than 120
min_length=3string has at least 3 characters
max_length=12string has at most 12 characters
Quick check: With age: int = Field(ge=13, le=120), which age bounces?
  • 13
  • 120
  • 121

13 and 120 are exactly on the limits, and ge/le include them. 121 is one past the maximum, so it bounces.

Read the status code

Byte: The first digit tells you who is to blame.

  • 2xx Success

    201 Created, 200 OK. The packet got in.

  • 4xx Client's fault

    422 invalid data, 404 not found. Bounced at the gate.

  • 5xx Server's fault

    500: your code crashed. The tower sparks.

When validation fails, FastAPI returns 422 Unprocessable Content with a list saying which field failed and why. The client sent bad data: that is a 4xx, the client's fault.

If your own code throws an exception, the client gets 500 Internal Server Error: that is a 5xx, your fault. In the city, 4xx packets bounce off the gate and 5xx packets make your server spark.

response 422
{
"detail": [
{
"type": "greater_than_equal",
"loc": ["body", "age"],
"msg": "Input should be greater than or equal to 13",
"input": 5
}
]
}
Quick check: Your handler reads body.nickname, but the model has no such field. What does the client get?
  • 422, bad request data
  • 500, the server crashed
  • 201, created

The request itself was fine. Your code crashed, so it's a 500. Keep the field names in the handler and the model in sync.