The Pydantic Boundary Model
Every Python service eventually has to answer the same question: what do you do with data that arrives from outside your program's control - an HTTP request body, a config file, a message off a queue, a row from a CSV someone uploaded?
Pydantic's answer is to turn that arriving data into a strongly-typed Python object exactly once, at the point it crosses into your code, and to reject or coerce anything that doesn't fit the shape you declared.
Pydantic Basics shows the syntax for defining that shape with BaseModel; this page is about the model underneath it - why validation happens where it happens, how pydantic-core makes that fast enough to use everywhere, and why dumping data back out is a genuinely different operation from describing its shape.
Summary
- Pydantic parses untrusted, loosely-typed input into a strongly-typed Python object at a single boundary crossing, then treats that object as trustworthy for the rest of its lifetime.
- Insight: Without a boundary layer, type-correctness checks get scattered across every function that touches external data, and "is this a valid
User" becomes a question every caller has to re-answer instead of a guarantee the type system already gives you. - Key Concepts: boundary validation, pydantic-core, core schema, coercion, strict mode, serialization.
- When to Use: Parsing request bodies, config files, and environment variables; validating rows coming out of a CSV or queue message; building typed domain objects that downstream code can trust without re-checking.
- Limitations/Trade-offs: Pydantic validates shape and type, not business rules that depend on other rows or external state; validation runs at construction, so mutating a model after creation bypasses those checks unless you explicitly opt back in.
- Related Topics: dataclasses and attrs, JSON Schema generation, field-level validators, settings management.
Foundations
The phrase "parse, don't validate" describes the mental shift Pydantic asks you to make.
A validation function that returns True or False tells you data was acceptable at one moment, but it hands you back the same untyped dict or str you started with, so every later function still has to trust that check happened correctly upstream.
A parser instead converts that data into a new, more specific type - a User object instead of a dict - and once that object exists, its existence is the proof that validation already succeeded.
BaseModel is Pydantic's parser: you declare the target shape with ordinary Python type hints, and instantiating the class with raw data either produces a fully-typed instance or raises a ValidationError that tells you exactly what didn't fit.
A useful analogy is a border checkpoint that also exchanges currency.
Data doesn't just get inspected and waved through in its original form; it gets converted into the local currency (your typed model) at the crossing point, so nothing downstream has to keep asking what currency it's holding.
Pydantic 2 rebuilt this checkpoint on pydantic-core, a validation engine written in Rust, which is why the same declarative style that was already convenient in Pydantic 1 became fast enough to use on every request in a high-traffic API rather than reserving it for form submissions and config loading.
Mechanics & Interactions
The performance story starts at class definition, not at instantiation.
When a BaseModel subclass is defined, Pydantic walks its type hints and field configuration and compiles them once into a core schema - a data structure pydantic-core understands - rather than re-interpreting your type hints from scratch every time an instance is created.
Every subsequent User(**data) call reuses that already-compiled schema and does the actual field-by-field checking inside the Rust extension, which is why validating a thousand records with Pydantic 2 costs roughly one schema compilation plus a thousand cheap native validations, not a thousand expensive dynamic type inspections.
class User(BaseModel):
id: int
email: str
# Schema for User is compiled once, here, at class definition:
User.__pydantic_core_schema__ # the compiled shape pydantic-core validates against
# Every instantiation below reuses that schema - it isn't recompiled per call:
User(id="1", email="a@example.com") # "1" coerces to int under lax (default) modeThat snippet also shows coercion, which is the default, "lax" behavior: a string "1" for an int field is accepted and converted, because in practice a lot of legitimate data (form fields, query strings, JSON numbers that arrived as text) is technically the wrong type but unambiguously convertible.
Strict mode turns that off, refusing anything that isn't already the declared type, which matters for boundaries where silent coercion would hide a real bug - an internal service-to-service call where a str id instead of an int id usually signals a caller error, not a formatting quirk.
Validators (field_validator, model_validator) layer custom logic onto the compiled schema rather than replacing it: a field_validator runs after pydantic-core has already coerced and type-checked that field, so it operates on data it can already trust is the right Python type, not on the raw input.
Advanced Considerations & Applications
model_dump(), model_dump_json(), and model_json_schema() sound like three flavors of the same feature, but they answer three unrelated questions, and conflating them is one of the more common architectural mistakes in Pydantic-heavy codebases.
model_dump() and model_dump_json() are serialization: they take an actual instance with real data and produce a Python dict or a JSON string from it, and Pydantic runs this through its own compiled serialization schema, which is why custom field_serializer logic and options like exclude_none or by_alias only affect these calls.
model_json_schema() never looks at an instance at all; it inspects the class definition and produces a JSON Schema document describing what a valid instance would look like, which is the shape FastAPI publishes as OpenAPI and the shape a client codegen tool consumes - it is a static description, not a data transform.
The practical consequence is that changing a field_serializer changes what model_dump_json() outputs but never changes model_json_schema(), and vice versa - a schema-level Field(description=...) shows up in the JSON Schema but has no effect on a dump.
Where validation should live is a related, and more consequential, architectural decision: pushing every check into the model at the HTTP boundary is not the same as pushing every check only there.
| Validation location | Strength | Weakness | Best Fit |
|---|---|---|---|
| Framework boundary (FastAPI request models) | Rejects malformed input before any handler code runs; free OpenAPI docs | Only sees one request in isolation; can't check rules spanning multiple resources | Shape, type, and single-field business rules on every inbound request |
Model-level validators (model_validator) | Cross-field rules stay next to the fields they constrain | Still limited to what's inside that one model instance | Rules like "end date must be after start date" |
| Service-layer checks | Can consult other data (uniqueness, ownership, quotas) | Easy to skip if a service method is called from multiple entry points | Rules that need a database lookup or another service's state |
| Database constraints | Enforced even if application code has a bug | Errors surface as generic DB exceptions, not friendly ValidationErrors | Final backstop for uniqueness, foreign keys, and non-null guarantees |
No single row replaces the others - a unique-email constraint at the database is the backstop for a check that should already have happened, faster and with a better error message, at the model or service layer.
Pydantic's role narrows as you move down that table: it owns shape and type at the edge, and it is not the layer responsible for "does this email already exist" or "is this user allowed to update this record."
Common Misconceptions
- "Pydantic validation means my data is business-rule-correct." Pydantic confirms a field is an
intwithin any declared constraints; it has no idea whether that integer is a valid account balance for this specific user, which is a service-layer or database-layer concern. - "
model_dump()and the old.dict()do the same thing.".dict()is a Pydantic 1 compatibility shim;model_dump()is the v2-native path that respects custom serializers and themode="json"distinction, and mixing the two in one codebase produces subtly inconsistent output. - "
model_json_schema()reflects runtime serialization behavior." It reflects the class's declared shape only; afield_serializerthat changes what a dump looks like has zero effect on the generated JSON Schema, because schema generation never runs an instance through serialization. - "Once a model is constructed, its fields stay valid." Validation runs at
__init__time; assigning a new value to an existing instance's attribute bypasses that check entirely unlessmodel_config = ConfigDict(validate_assignment=True)is set. - "Strict mode is what you should use everywhere for safety." Strict mode rejects legitimate, commonly-arriving data like numeric strings from query parameters; lax (the default) coercion is what makes Pydantic usable at HTTP boundaries in the first place, and strict mode is for narrower, already-typed internal boundaries.
FAQs
What does "parse, don't validate" actually mean in Pydantic's context?
It means Pydantic doesn't just return True/False on whether data is acceptable - it converts that data into a new, typed object.
Once a User instance exists, its existence is the proof that validation already passed, so callers don't need to re-check it.
Why is Pydantic 2 so much faster than Pydantic 1?
Pydantic 2 moved the actual validation work into pydantic-core, a Rust extension, instead of doing it in pure Python. The Python-facing API (BaseModel, type hints) stayed familiar, but the schema is compiled once and executed natively on every instantiation.
What is a "core schema" and when does it get built?
It's the compiled internal representation of a model's shape that pydantic-core actually validates against. It's built once, when the BaseModel subclass is defined - not re-derived from your type hints on every call.
What's the difference between lax mode and strict mode?
- Lax mode (the default) coerces compatible types, like a numeric string into an
int. - Strict mode rejects anything that isn't already the exact declared type.
- Lax mode fits loosely-typed boundaries like HTTP query strings; strict mode fits already-typed internal service calls.
How is `model_dump()` different from `model_json_schema()`?
model_dump() takes a real instance's data and serializes it to a dict or JSON. model_json_schema() never touches an instance - it inspects the class definition and produces a JSON Schema describing valid shape. Changing a serializer affects the first, never the second.
Do field validators run before or after type coercion?
After. A field_validator receives data that pydantic-core has already coerced and confirmed matches the declared type, so custom validator code can assume it's working with the right Python type already.
Does Pydantic keep validating a model after it's created?
No, not by default. Validation happens at construction; mutating an attribute afterward skips that check unless the model sets model_config = ConfigDict(validate_assignment=True).
Where should business rules that need a database lookup live?
Not inside the Pydantic model - it has no access to a database session. Those belong in a service layer that can query for things like uniqueness or ownership, with the database itself as a final backstop constraint.
Is Pydantic only useful for FastAPI request bodies?
No - it's used just as often for settings/config loading, parsing queue messages, validating rows from a file, and defining typed domain objects passed between internal services, independent of any web framework.
Why does Pydantic reject unknown fields by default in some configurations?
Rejecting unexpected keys (extra="forbid") catches typos and unexpected payload shapes early, at the boundary, instead of silently ignoring data the caller thought was being used.
Can two different pieces of code get inconsistent results from the same model?
Yes, if one uses the legacy .dict() compatibility method and another uses model_dump() - they can diverge on how custom serializers and JSON-specific conversions are applied. Standardizing on model_dump()/model_dump_json() avoids that drift.
What's the honest limitation of the boundary-validation model?
It only knows what's inside the payload being validated - it can't check things like uniqueness against other rows or a second resource's state, so it has to hand off to a service layer or database constraint for anything that isn't self-contained.
Related
- Pydantic Basics - the hands-on syntax for defining models this page explains conceptually
- Performance & pydantic-core - how the compiled schema and Rust core behave under load
- Serialization -
model_dump, custom serializers, and the JSON-mode distinction in detail - Validators -
field_validatorandmodel_validatormechanics - Dataclasses vs Pydantic vs attrs - when boundary validation is worth the overhead versus a plain dataclass
Stack versions: This page was written for Python 3.14.0 (stable 3.14, maintenance 3.13) and Pydantic 2.