A type annotation does nothing at runtime. Pydantic is the library that makes it mean something.
Overview
The gap between what you wrote and what runs
Write a function like this and you have made a promise:
def modules_left(count: int) -> str:
return "You have " + str(count) + " modules to go"
The promise is that count is a whole number. Python does not keep it. Annotations are stored on the function object and otherwise ignored at runtime — they exist for human readers, editors and type checkers like mypy, none of which are present when your program is actually running. Call modules_left("twelve") and Python does exactly what you asked: it concatenates strings and returns a sentence that reads fine.
That is a small problem when the wrong value is only printed. It becomes a real one the moment something arithmetic happens. A string that has been travelling through three function calls disguised as an integer fails at the point it is finally divided, and the traceback points at the division, not at the front door where the bad value walked in.
This is the gap Pydantic fills. It reads the annotations you already write and enforces them, at a moment you choose.
Worth knowing
Pydantic does not replace type hints — it reads the ones you already write. The annotation is the schema.
Validation happens when a model is created. Assigning to an attribute afterwards is not checked unless you ask for it with model_config = ConfigDict(validate_assignment=True).
model_validate_json parses and validates in one step. It is faster than json.loads followed by model_validate, because the parsing happens in Rust.
Pydantic v2's core is written in Rust. That is why validating at the boundary is cheap enough to do on every request.
The library is used by FastAPI, LangChain, HuggingFace and most of the modern Python API stack. Learning it once pays off across all of them.
If a value is already the right type, validation is nearly free — there is nothing to convert.
What Pydantic Is For, and the Problem It Solves
Python's type annotations do nothing on their own. This is the library that makes them real.
A type hint is only a note to the reader
Python does not check annotations at runtime. This function promises an int and is handed a string, and Python raises nothing at all — the failure arrives later, somewhere else, as something confusing.
example_01.pyPydantic
Output
The same promise, enforced
A Pydantic model reads the same annotations and actually applies them. Nothing about the type declarations changed — only who is reading them.
example_02.pyPydantic
Output
It converts, not just complains
Data arriving from JSON, a form or a query string is text. Pydantic converts what can be converted and rejects what cannot, so the rest of your program works with real types.
example_03.pyPydantic
Output
Errors that name the problem
When it does refuse, it refuses with detail: which field, what was wrong, and what it actually received. Every field is checked, so you get the whole list rather than the first failure.
example_04.pyPydantic
Output
Where a model belongs
Pydantic earns its place at the boundary — the line where data you did not write enters code you did. Validate once on the way in, and everything downstream can stop guessing.
example_05.pyPydantic
Output
What you get for free
Because the model already describes the data precisely, it can also hand that description to other tools — as a dict, as JSON, or as a schema that documentation and API tooling can read.
example_06.pyPydantic
Output
Validation is a boundary problem
Almost no bad data originates inside your program. It arrives: from a JSON request body, a form post, a config file, a CSV, a database column that has been nullable since 2019, an environment variable, another team's API. Everything that crosses into your code from outside is, until proven otherwise, a guess.
The useful discipline is to check that data once, at the edge, and convert it into something trustworthy. After that line, every function downstream can assume the shape is right and stop defending itself. The alternative — an isinstance check at the top of every function, or worse, no check and a hope — spreads the same anxiety through the whole codebase.
A Pydantic model is that boundary written down. It is a class whose annotations describe what you require, and constructing one is the act of checking.
from pydantic import BaseModel
class Module(BaseModel):
title: str
minutes: int
published: bool
Three lines, and you now have something that will refuse to exist unless the data fits.
Coercion: the part people do not expect
The first surprise for most people is that Pydantic does not merely check — it converts. Hand the model above minutes="9" and you get back an integer 9, not an error.
That is deliberate, and it follows from where models are used. Data crossing a boundary is usually text. A query string has no integers in it. An HTML form sends strings. A CSV is strings all the way down. If a model rejected everything that arrived as text, you would spend your life writing int(request.args["minutes"]) and catching ValueError by hand — which is precisely the code Pydantic exists to delete.
So the default behaviour, called lax mode, is to accept anything that has an unambiguous reading. "9" becomes 9. 9.0 becomes 9. "true", "yes" and 1 all become True. But "nine" does not become anything, because there is no unambiguous reading, and neither does 9.5, because turning it into 9 would silently lose information you might have needed.
Where that trade is wrong — and sometimes it is, particularly deep inside a system where types should already be correct — strict mode is available, and gets a module of its own later in this track.
Errors that are worth reading
The second thing that distinguishes Pydantic from a hand-written check is the quality of its refusals.
A hand-written validator usually raises on the first problem it meets. The caller fixes that one, resubmits, and discovers the next. Pydantic checks every field and raises once, carrying the complete list. For a form, that is the difference between one error message at a time and a form that highlights all four broken inputs at once.
Each entry in that list has three parts worth reading separately.
The location is a path, not a name. For a flat model it is just the field. For anything nested it describes the route: address.pin is one level down, modules.2.minutes is the minutes field of the third item in a list. When a payload is deep, this is the fastest way to find the offending value.
The type is a stable machine-readable code — int_parsing, string_too_short, greater_than — not a sentence. It is what you match on if you are converting errors into an API response or a translated message, and it will not change underneath you when the wording is improved.
The input is the value that was actually received. Nine times in ten, seeing it is the whole diagnosis: you expected a number and the caller sent "null" as a string.
What else the model buys you
Once the shape of your data is written down precisely enough to validate against, it is written down precisely enough for other things too.
model_dump() gives you a plain dictionary. model_dump_json() gives you a JSON string, handling the types — dates, UUIDs, decimals — that json.dumps refuses. And model_json_schema() produces a JSON Schema document describing the model.
That last one is quietly the reason Pydantic is everywhere. FastAPI does not have its own validation layer; it uses Pydantic, and it turns those generated schemas into the interactive documentation you get for free at /docs. The same mechanism drives structured output in LLM libraries, where the schema tells the model what shape to answer in. You describe your data once, and several tools read that description.
What it is not
Pydantic is not an ORM, though it is often paired with one. It does not talk to your database, and a model is not a table.
It is not a static type checker either. Mypy analyses code without running it; Pydantic checks values while the program runs. They complement each other rather than compete, and using both is normal.
And it is not free. Validation costs something, which is exactly why the boundary discipline matters: validate on the way in, once, and then trust the result rather than re-checking the same object at every layer.
What the hand-written version looks like
It is worth seeing the code Pydantic replaces, because the comparison explains several of its design decisions at once.
def parse_module(raw):
if not isinstance(raw, dict):
raise ValueError("expected an object")
title = raw.get("title")
if not isinstance(title, str):
raise ValueError("title must be a string")
minutes = raw.get("minutes")
if isinstance(minutes, str):
try:
minutes = int(minutes)
except ValueError:
raise ValueError("minutes must be a number")
elif not isinstance(minutes, int):
raise ValueError("minutes must be a number")
return {"title": title, "minutes": minutes}
Fifteen lines for two fields, and it is already worse than it looks. It stops at the first error, so a caller with two mistakes discovers them one at a time. Its messages do not say which field failed in a machine-readable way. It has no idea what to do about a third field when somebody adds one, and nothing forces that person to remember this function exists. And it describes the same shape a second time, in prose, in whatever documentation exists.
The Pydantic version is the class definition and nothing else. Every one of those problems is solved as a side effect rather than as an additional feature.
The cost, and when it matters
Validation is not free, and it is worth being concrete rather than reassuring.
Building a model does real work: reading a compiled schema, checking each field, converting where needed, and constructing the object. A plain class assignment does none of that. If you construct millions of objects in a tight loop, you will measure the difference.
Two things make it matter less than people fear. The first is that Pydantic v2 does the work in Rust rather than Python, which moved it from "noticeable" to "usually irrelevant". The second is scale: a model that validates in single-digit microseconds sits next to a database query taking milliseconds and a network call taking hundreds of milliseconds. In a typical request handler, validation is a rounding error on the request.
Where it genuinely matters is bulk. Validating a hundred thousand rows from a file, or every element of a large array in a numerical pipeline, is a case where you should measure. The answer there is usually not to abandon models but to validate the container once rather than each item through a Python loop, which is a topic the TypeAdapter module returns to.
The rule that follows is the one this article opened with: validate at the boundary, once. Re-validating an object that has already passed is pure cost with no information gained.
Where it came from, and why v2 matters
Pydantic v1 was pure Python. It was popular enough to become a dependency of a large part of the ecosystem, and slow enough that its performance was a recurring complaint.
Version 2, released in 2023, kept the API broadly recognisable and rewrote the engine in Rust as a separate package called pydantic-core. When you install pydantic you get both: a Python layer that reads your annotations and builds a schema, and a compiled core that executes that schema against data.
This split explains several things you will notice. It is why validation is fast. It is why the error type codes look like machine identifiers rather than sentences — they come from the core. It is why model_validate_json beats json.loads plus validation, since the core parses and validates in one pass without building intermediate Python objects. And it is why a wheel exists for every platform: there is compiled code in there.
The practical consequence for you is about documentation. A great deal of Pydantic material online predates v2 and describes an API that has moved. The reliable signals: v1 uses @validator, .dict(), .json() and class Config; v2 uses @field_validator, .model_dump(), .model_dump_json() and model_config. If an article uses the first set, treat everything in it as historical.
Four things people expect it to be
An ORM. It is not, and it does not talk to a database. It pairs well with one — SQLModel exists precisely to join them — but a model is not a table and validating one does not persist anything.
A static type checker. Also no. Mypy and Pyright analyse code without running it and catch mistakes in your own source. Pydantic checks values at runtime and catches mistakes in data. They overlap in vocabulary and not in job, and a serious codebase uses both.
A serialisation format.model_dump_json produces JSON, but Pydantic is not competing with json or msgpack. It describes and checks the shape; the encoding is a service it offers on top.
Automatic. Nothing validates until you ask. A function annotated with a model type does not check its argument; only constructing or validating a model does. There is a @validate_call decorator that brings the same checking to function arguments, and it is opt-in for the same reason.
The habit worth forming
When you find yourself writing if not isinstance(...), or reaching into a dictionary with .get() and a default, or writing a comment that explains what shape a parameter is meant to have — that is a model waiting to be written.
The comment in particular is the strongest signal. A sentence describing the shape of data is a schema that cannot be executed, checked or kept honest. Turning it into a model costs about the same number of lines and cannot go stale.
Where this track goes
The next module builds a first model properly — fields, required versus optional, and what you get back. After that comes the part everyone trips over: precisely which values Pydantic will convert and which it will refuse, and how to read the error when it refuses.
The fastest way through all of it is to keep changing the values in the editors above and pressing Run. A rule you have watched break is a rule you remember.
Check yourself
0 of 4
Answer without scrolling back up.
What does a plain Python type annotation do at runtime?
Annotations are stored and ignored while the program runs. They serve readers, editors and static checkers. Pydantic is one of the tools that chooses to act on them.
A model field is annotated `minutes: int` and receives the string `"9"`. What happens by default?
Default lax mode converts anything with an unambiguous reading, because data crossing a boundary usually arrives as text. `"nine"` would raise, because there is no unambiguous reading.
Why does Pydantic report every invalid field rather than stopping at the first?
One raise carrying the full list means a form can highlight all its broken inputs at once, instead of revealing them one resubmission at a time.
Where does a Pydantic model earn its place?
Validate once where untrusted data arrives, and everything downstream can assume the shape is correct. Re-checking at every layer costs time and adds no safety.
Cheat sheet
What Pydantic Is For
The promise is that count is a whole number. Python does not keep it. Annotations are stored on the function object and otherwise ignored at runtime — they exist for human readers, editors and type checkers like mypy, none of which are present when your program is actually running. Call modules_left("twelve") and Python does exactly what you asked: it concatenates strings and returns a sentence that reads fine.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.