Never trust the request
Byte: Everything that reaches your server was written by a stranger.
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.
JSON body
{"age": "old"}
SignupRequest model
types checked
invalid → 422, handler skipped
your handler
only runs if valid
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.
from fastapi import FastAPIfrom pydantic import BaseModelapp = FastAPI()class SignupRequest(BaseModel):username: strage: 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.
from pydantic import BaseModel, Fieldclass SignupRequest(BaseModel):username: str = Field(min_length=3, max_length=12)age: int = Field(ge=13, le=120)
| Constraint | Means |
|---|---|
| ge=13 | greater than or equal to 13 (13 is allowed) |
| gt=13 | strictly greater than 13 (13 bounces) |
| le=120 | less than or equal to 120 |
| lt=120 | strictly less than 120 |
| min_length=3 | string has at least 3 characters |
| max_length=12 | string 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.
{"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.