Pydantic V2: Make Python Take Types Serious
- Mic

- Apr 7
- 5 min read
Data arrives and you need to trust it. Between the moment JSON hits your application and the moment your code uses it, something needs to happen. You can write `if` statements and hope. Or you can make validation structural.
This post is about Pydantic V2 — not as a tutorial, but as an architectural decision. We'll look at what changed from V1, why it matters, and when the complexity cost is worth paying.

The Baby Example
Here's the simplest case. You receive some JSON and need a Python object:
Notice what happened, the string "30" became an integer without any extra input from your side. Pydantic calls this coercion, and it's one of those features that's either brilliant or dangerous depending on context.
What it really is, is type strictness and enforcement of conversion.
Performance but also Safety
V2 rewrote the validation engine in Rust. This is not a small detail from a performance point of view.
V1 was pure Python, making it readable, debuggable, but also slow enough to matter at scale. V2's core is compiled, which means validation runs 5–17x faster depending on your schema complexity.
But speed was not the real win. The real difference is what you can validate now without performance guilt.
In V1, nested models with repeated validation got expensive fast. Teams wrote workarounds, like caching validation results, or validating once at the boundary then trusting downstream. V2 lets you validate everywhere because the cost dropped.
Here's a slightly less trivial example:
The nested Address objects validate independently. Pydantic walks the structure recursively and builds the error tree if something breaks.
Worth noting: this includes regex validation on postal_code running inside a loop. In V1, this added up, while in V2, it's negligible.
Strict Mode: Coercion is Optional
V2 introduced a mode V1 never had: strict validation. Essentially disabling conversion, forcing data to arrive in the correct form.
By default, Pydantic is permissive. For example the string "30" becomes an int 30, or the string "true" becomes the bool True. This is useful when parsing web forms or query params where everything arrives as text.
But sometimes you want Python's type system enforced exactly as written. No conversion that is not directly controlled from your end.
Strict mode is worth having when:
You control the producer (it's your API writing the JSON)
Coercion hides bugs (a ZIP code shouldn't silently become an integer)
You want contract-level guarantees, not "best effort" parsing
The trade-off: you lose flexibility. If your input is genuinely messy, whether this is from scraping, user uploads, or legacy integrations, permissive mode is the reasonable default and might be the only way to go.
Validation Happens Twice
One thing V2 makes explicit: there are two validation phases. These are
Phase 1: Python validation Checks the structure using Python types. This runs every time unless you explicitly disable it.
Phase 2: Pydantic validation Uses Field() constraints, custom validators, and business logic rules.
This distinction matters, as we can see in this example
The @field_validator decorator runs after the type check passes. This layering is structural, this means you can compose validations without worrying about type safety underneath.
V1 had validators too, but V2 reorganized the API to make the phases more visible. You can now hook into different points in the validation pipeline.
Serialization: The Return Trip
Pydantic is not just about parsing inbound data. It's also about producing outbound data.
Notice the difference, while model_dump() gives you Python objects, model_dump(mode='json') converts everything to JSON-serializable types.
This is surprisingly useful when building APIs. You validate on the way in, process with Python objects, then serialize on the way out. And especially it is all using the same model definition.
V1 called this .dict(), V2 renamed it to make the behavior clearer and added the mode parameter.
Custom Types: When Built-ins Aren't Enough
Sometimes str and int don't capture your domain and you need custom types. Instead of the following example
We can create fully custom types using __get_pydantic_core_schema__.
This creates a new type Username that comes with a new validation method that is automatically used in type validation. This lets you teach Pydantic about domain objects like UUIDs, lat/lng pairs, or currency amounts without writing validators everywhere.
The pattern: extract the validation logic once, reuse it across models.
Model Inheritance: Composing Schemas
Models can be composed, this is one of Pydantic's underrated features.
BlogPost inherits all fields from both parent classes and adds it's own three fields. You can easily build reusable schema fragments and mix them where needed.
This matters especially for APIs with consistent metadata, for example everything gets created_at and updated_at without repeating the definition. Of course these fragments can then come with their own included validation methods.
When NOT to Use Pydantic
Pydantic is not free, even after the performance improvements. Here's when the cost outweighs the benefit:
Small scripts - If your data flow is 10 lines and runs once, dictionaries are fine. Pydantic adds import time and cognitive overhead.
Performance-critical inner loops - Even with the Rust rewrite, validation costs something. If you're processing millions of rows per second and the data is already clean (e.g., from a trusted database), skipping validation might matter.
Dynamic schemas - Pydantic wants a fixed schema at import time. If your fields change per request (rare, but it happens), dictionaries or TypedDict might be more appropriate.
Prototyping unknowns - When you're still figuring out what the data looks like, enforcing a schema too early slows you down. In this case validation should probably be added later on in the process.
V1 to V2: What Changed
If you're migrating, here are the differences that matter:
V1 | V2 | Why it changed |
.dict() | .model_dump() | Clearer intent |
.json() | .model_dump_json() | Explicit serialization |
`Config` class | `ConfigDict` | Type-safe configuration |
`@validator` | `@field_validator` | Explicit field targeting |
`allow_mutation=False` | `frozen=True` | Standard Python naming |
The mental model shifted from "magic class attributes" to "explicit function calls". This makes the library easier to reason about but requires mechanical refactoring.
Most projects can migrate in an afternoon. The Pydantic team provides a migration guide and a codemod tool.
The Real Difference
Here's what Pydantic actually buys you:
Refactoring safety - When you change a model, every callsite either validates or breaks at parse time. You don't ship bugs where field names drifted.
Documentation that runs - The schema is the documentation. It's never out of sync because it's the code.
Boundary enforcement - Data enters your system through well-defined contracts, so that everything downstream can trust the shape.
This is not about validating for validation's sake. It's about making invalid states unrepresentable, as a type of structural guarantee rather than a runtime hope.
Where This Fits
If you read the validation series from a few weeks back, Pydantic is one answer to the "where do you validate" question.
It sits at boundaries. API routes, message queues, file parsers, essentially anywhere external data enters your application. Combine it with type checkers like mypy and you get both static and runtime guarantees. The overlap is what makes it powerful.
Next, we'll look at Pydantic's settings management and how it replaces environment variable parsing. Turns out, configuration is just validation in disguise.

Comments