The most expensive bugs I have debugged all had the same shape. A value that should never have existed got into the system, passed through three or four transformations picking up plausibility as it went, and surfaced somewhere completely unrelated as a number that was simply wrong.
Nothing crashed. That was the problem.
The fix is not clever. It is to decide, once, that data gets validated at the boundary and nowhere else has to wonder.
Validate at the edge, trust inside
Draw one line around your application. Everything crossing inward gets checked. Once past it, code can assume the data is what it claims to be.
In FastAPI that line is the request model.
from pydantic import BaseModel, Field, field_validator
from datetime import date
class ReadingIn(BaseModel):
sensor_id: str = Field(min_length=3, max_length=64)
celsius: float = Field(ge=-80, le=200)
recorded_on: date
@field_validator("recorded_on")
@classmethod
def not_in_future(cls, v: date) -> date:
if v > date.today():
raise ValueError("recorded_on cannot be in the future")
return v
Four lines of constraint, and an entire category of downstream confusion stops being possible. A sensor cannot report 4000 degrees. A reading cannot arrive from next Tuesday.
Reject, do not coerce
This is the part people get wrong, and Pydantic v1 made it easy to get wrong by default.
If a client sends "celsius": "23.5" as a string, you have a choice. You can
quietly turn it into a float, or you can refuse it. Quietly converting feels
helpful. It is not.
Coercion hides the fact that a client is sending the wrong type. It works until
someone sends "23.5 C", or "", or "null", and then you are debugging a
parse error in a code path that has no idea where the value came from.
Pydantic v2 is strict by default about many of these, and you can be explicit:
from pydantic import BaseModel, ConfigDict
class ReadingIn(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")
extra="forbid" is the underrated half. Without it, a client that misspells
sensorId as sensor_Id gets a cheerful 200 and a record with a missing
field. With it, they get a 422 that names the problem. Their bug becomes
visible in their own logs instead of yours.
Separate the shapes
One mistake worth avoiding: using the same model for input, storage, and output.
They are three different things with three different rules. The input model
should not accept an id, because the client does not get to choose it. The
output model should not expose password_hash, because nobody gets to see it.
The database model answers to the schema, not to the API.
class ReadingIn(BaseModel): # what a client may send
sensor_id: str
celsius: float
class ReadingOut(BaseModel): # what a client gets back
id: int
sensor_id: str
celsius: float
created_at: datetime
class Reading(Base): # SQLAlchemy, what is stored
...
It looks like duplication. It is not. It is three contracts that are allowed to change independently, which is the whole point. The day you need to add an internal field, you will not accidentally publish it.
Make the error useful
A 422 that says invalid input teaches nobody anything. FastAPI’s default
validation error already names the field, the location, and the rule that
failed. Keep that. If you catch and reshape validation errors, preserve those
three things.
The test is simple: can the person receiving the error fix their request without asking you? If not, the error is not finished.
Where this pays off
On the digital twin platform I worked on, data arrived from several sources and did not always match its own documentation. Putting a strict Pydantic model on every ingest path meant bad records were rejected at the edge, named and logged, instead of becoming a plausible-looking wrong number in an analytics output three layers down.
The reason to do this is not tidiness. It is that a rejected request is a five-minute fix and a corrupted dataset is a week.
The short version
- One validation boundary. Check on the way in, trust inside.
- Reject the wrong type, do not coerce it.
- Set
extra="forbid"so typos fail loudly. - Separate input, output, and storage models.
- Write errors that the caller can act on alone.