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
Pydantic takes a Python class with type annotations and turns it into a data validator. You define a model, pass in a dictionary or JSON, and get back a typed object if the data fits. If it does not, you get an error that names every field that failed and why.
That description sounds simple, but the range of what it covers is wide. Pydantic handles type coercion, so a string "42" arriving from JSON becomes the integer 42 without any code from you. It produces JSON Schema from your models, which feeds OpenAPI docs when used with a framework like FastAPI. It validates nested structures, enforces constraints on individual fields, and gives you hooks for custom logic that runs during validation.
This article explains what problem Pydantic solves, how it differs from plain dataclasses, and what coercion means in practice. It then covers the error format, the extra things a model gives you beyond validation, and the common expectations people bring that do not match how the library actually works. The final section maps out where the rest of the track goes.
Write a function like this and you've 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 doesn't keep it. Annotations are stored on the function object and else skipped at runtime. They exist for human readers, editors and type checkers like mypy, none of which are present when your program is really running. Call modules_left("twelve") and Python does just 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 turns a real one the moment a thing math happens. A string that has been travelling through three function calls disguised as an int fails at the point it's finally cut. 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
# The annotation says int. Python does not care.
def modules_left(count: int) -> str:
return "You have " + str(count) + " modules to go"
print(modules_left(12))
print(modules_left("twelve")) # no error - the annotation is decoration
print(modules_left(None)) # still no error
# The damage shows up later, in arithmetic that should have worked:
def halfway(count: int) -> float:
return count / 2
print(halfway(12))
try:
print(halfway("twelve"))
except TypeError as e:
print("TypeError:", e) # <- the real failure, far from the cause
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
from pydantic import BaseModel, ValidationError
class Reader(BaseModel):
name: str
modules_done: int
ok = Reader(name="Ada", modules_done=12)
print("valid :", ok)
try:
Reader(name="Ada", modules_done="twelve")
except ValidationError as e:
print("caught :", e.errors()[0]["msg"])
# The point: the failure happens here, at the moment the bad value arrives,
# not three functions later when someone tries to divide by it.
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
from pydantic import BaseModel
class Module(BaseModel):
title: str
minutes: int
published: bool
# Everything here arrives as a string, the way a web form would send it.
m = Module(title="Vectors", minutes="9", published="true")
print(m)
print("minutes is :", type(m.minutes).__name__, "->", m.minutes + 1)
print("published is:", type(m.published).__name__, "->", not m.published)
# Try changing "9" to "nine" and running this again.
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
from pydantic import BaseModel, ValidationError
class Module(BaseModel):
title: str
minutes: int
published: bool
try:
Module(title=None, minutes="nine", published="maybe")
except ValidationError as e:
print("problems found:", e.error_count())
print()
for err in e.errors():
print("field :", err["loc"][0])
print(" said:", err["msg"])
print(" got :", repr(err["input"]))
print()
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
import json
from pydantic import BaseModel, ValidationError
# Pretend this arrived from an API, a file, or a form post.
payload = '{"title": "Dot Product", "minutes": "11", "published": "yes"}'
class Module(BaseModel):
title: str
minutes: int
published: bool
module = Module.model_validate_json(payload) # one line: parse AND check
print("parsed :", module)
# From here on, minutes really is an int. No isinstance checks needed.
def reading_time(m: Module) -> str:
return "about %d minutes" % round(m.minutes * 1.2)
print("derived :", reading_time(module))
bad = '{"title": "Norms", "minutes": "a while", "published": "yes"}'
try:
Module.model_validate_json(bad)
except ValidationError as e:
print("rejected:", e.errors()[0]["msg"])
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
from pydantic import BaseModel
class Module(BaseModel):
title: str
minutes: int
published: bool
m = Module(title="Eigenvalues", minutes=14, published=True)
print("as a dict :", m.model_dump())
print("as JSON :", m.model_dump_json())
print()
print("the schema it generated:")
schema = m.model_json_schema()
for name, spec in schema["properties"].items():
print(" %-10s %s" % (name, spec.get("type")))
print(" required:", schema["required"])
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 DB column that has been nullable since 2019, an setting var, another team's API. all that crosses into your code from outside is, until proven else, a guess.
The handy discipline is to check that data once, at the edge. Convert it into a thing trustworthy. After that line, each function downstream can assume the shape is right and stop defending itself. The option. An isinstance check at the top of each function, or worse, no check and a hope. Spreads the same anxiety through the whole code.
A Pydantic model is that boundary written down. It's a class whose annotations show what you need. Making 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 a thing 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 doesn't merely check. It converts. Hand the model above minutes="9" and you get back an int 9, not an error.
That is deliberate, and it follows from where models are used. Data crossing a boundary is often text. A query string has no ints in it. An HTML form sends strings. A CSV is strings all the way down. If a model rejected all that arrived as text, you'd spend your life writing int(request.args["minutes"]) and catching ValueError by hand. Which is just the code Pydantic exists to delete.
So the default action, called lax mode, is to accept anything that has an unambiguous reading. "9" turns 9. 9.0 turns 9. "true", "yes" and 1 all become True. But "nine" doesn't become anything. There's no unambiguous reading. Neither does 9.5, because turning it into 9 would silently lose data you might have needed.
Where that trade is wrong. And at times it's, above all deep inside a system where types should already be correct — strict mode is on hand. Gets a module of its own later in this track.
Errors that are worth reading
The second thing that sets apart Pydantic from a hand-written check is the quality of its refusals.
A hand-written checker often raises on the first problem it meets. The caller fixes that one, resubmits. Discovers the next. Pydantic checks each field and raises once, carrying the full list. For a form, that is the gap 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 apart.
The location is a path, not a name. For a flat model it's just the field. For anything nested it shows 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's what you match on if you're converting errors into an API response or a translated message. It won't change underneath you when the wording is better.
The input is the value that was really received. Nine times in ten, seeing it's 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 just enough to validate against, it's written down just enough for other things too.
model_dump() gives you a plain dict. model_dump_json() gives you a JSON string, handling the types. Dates, UUIDs, decimals. That json.dumps refuses. And model_json_schema() makes a JSON Schema doc showing the model.
That last one is quietly the reason Pydantic is all over. FastAPI doesn't have its own check layer. It uses Pydantic. It turns those made schemas into the interactive docs you get for free at /docs. The same method drives structured output in LLM libraries. Here The schema tells the model what shape to answer in. You show your data once. A few tools read that description.
What it is not
Pydantic isn't an ORM, though it's often paired with one. It doesn't talk to your DB. A model isn't a table.
It isn't 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 isn't free. check costs a thing. This Is just why the boundary discipline matters. Validate on the way in, once. Then trust the result rather than re-checking the same object at each layer.
What the hand-written version looks like
It's worth seeing the code Pydantic replaces. The compare shows a few of its design choices 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's already worse than it looks. It stops at the first error. A caller with two mistakes discovers them one at a time. Its messages don't 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 note this function exists. And it shows the same shape a second time, in prose, in what docs exists.
The Pydantic version is the class meaning and nothing else. each one of those problems is solved as a side effect rather than as an extra feature.
The cost, and when it matters
check isn't free, and it's worth being concrete rather than reassuring.
Building a model does real work: reading a compiled schema, checking each field, converting where needed. Making the object. A plain class set does none of that. If you make millions of objects in a tight loop, you'll measure the gap.
Two things make it matter less than people fear. The first is that Pydantic v2 does the work in Rust rather than Python. This Moved it from "noticeable" to "often irrelevant". The second is scale. A model that validates in single-digit microseconds sits next to a DB query taking milliseconds and a network call taking hundreds of milliseconds. In a common request handler, check is a rounding error on the request.
Where it truly matters is bulk. checking a hundred thousand rows from a file, or each element of a large array in a numeric pipeline, is a case where you should measure. The answer there's often not to abandon models but to validate the holder once rather than each item through a Python loop. This 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-checking an object that has already passed is pure cost with no data gained.
Where it came from, and why v2 matters
Pydantic v1 was pure Python. It was common enough to become a dependency of a large part of the ecosystem. Slow enough that its speed was a recurring complaint.
Version 2, released in 2023, kept the API broadly recognisable and rewrote the engine in Rust as a split package called pydantic-core. When you install pydantic you get both. A Python layer that reads your annotations and builds a schema. A compiled core that runs that schema against data.
This split shows a few things you'll notice. It's why check is fast. It's why the error type codes look like machine identifiers rather than sentences. They come from the core. It's why model_validate_json beats json.loads plus check. The core parses and validates in one pass without building mid Python objects. And it's why a wheel exists for each platform: there's compiled code in there.
The hands-on result for you is about docs. A great deal of Pydantic material online predates v2 and shows an API that has moved. The solid signals. V1 uses @checker, .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 all in it as historical.
Four things people expect it to be
An ORM. It isn't, and it doesn't talk to a DB. It pairs well with one. SQLModel exists just to join them. But a model isn't a table and checking one doesn't 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 share in vocab and not in job. A serious code uses both.
A output format.model_dump_json makes JSON. Pydantic isn't competing with json or msgpack. It shows and checks the shape. The encoding is a service it offers on top.
auto. Nothing validates until you ask. A function annotated with a model type doesn't check its arg. Only making or checking a model does. There's a @validate_call decorator that brings the same checking to function args. It's opt-in for the same reason.
The habit worth forming
When you find yourself writing if not isinstance(...), or reaching into a dict with .get() and a default, or writing a comment that shows what shape a param is meant to have. That is a model waiting to be written.
The comment in given is the strongest signal. A sentence showing the shape of data is a schema that can't be ran, checked or kept honest. Turning it into a model costs about the same number of lines and can't go stale.
Questions people ask
What is Pydantic used for? Validating data against Python type hints and converting it to the right types. It is common for API input, configuration files and parsing external data.
Is Pydantic a web framework? No. It is a data validation library. FastAPI uses it, but it works in any Python code.
Does it check types when I assign to a model? Not by default. It checks when the model is created. Set validate_assignment=True to check later assignments too.
Do I get errors for all fields or just the first? All of them, collected in one ValidationError.
Is it slow? Version 2 runs its core in Rust and is fast enough for most services. Cost grows with the amount of data, not the number of models.
Which version should I learn? Version 2. It is the current major release and the one FastAPI and most libraries target.
Where this track goes
The next module builds a first model properly. Fields, needed versus optional. What you get back. After that comes the part all trips over. just which values Pydantic will convert and which it will refuse. How to read the error when it refuses.
The fastest way through all of it's to keep changing the values in the editors above and pressing Run. A rule you've watched break is a rule you note.
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
See how Pydantic turns Python type annotations into runtime checks that reject bad data the moment it arrives.
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.