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.

Related

Next step

Working on something this touches?

If this post lines up with a problem you have, I would rather hear about the problem than the post. Tell me what you are building.

  • Replies: Within two working days
  • Based in: the United States
  • Open to: full time, contract, remote or hybrid

Pages

Projects

Writing

Actions