Defining a model, creating one, reading it back, and turning it into a dict or JSON when you are done.
Overview
The whole idea in three lines
A Pydantic model is a class that inherits from BaseModel and lists its fields as annotations.
from pydantic import BaseModel
class Module(BaseModel):
title: str
track: str
minutes: int
That is the entire definition. There is no __init__, no assignment of self.title = title, no validation code. Pydantic reads the annotations when the class is created and builds the machinery from them — a constructor, a validator, a serialiser, an equality method and a readable repr.
The economy matters more than it first appears. Adding a field is one line. In the hand-written equivalent it is four: the parameter, the assignment, the check, and the line in to_dict. Those four drift apart over time, and the drift is where bugs live.
Worth knowing
There is no __init__ to write. Pydantic generates one from the annotations, which is why adding a field is a one-line change.
Positional arguments are deliberately not allowed. Fields are keyword-only so that adding one can never silently change what an existing call means.
Module(**data) and Module.model_validate(data) do the same job. The second reads better when the data is already a dict, and is the one to reach for in a pipeline.
Models compare by value: two models of the same class with the same field values are ==. That makes assertions in tests short.
model_dump() gives Python objects (a datetime stays a datetime); model_dump_json() gives JSON-safe text. Reach for the second when something is leaving the process.
In Pydantic v1 these were .dict() and .json(). Both still exist in v2 but are deprecated — most tutorials you find will use the old names.
Your First BaseModel, End to End
Defining a model, creating one, reading it back, and getting the data out again.
Subclass, annotate, done
A model is a class inheriting from BaseModel whose annotations list the fields. There is no __init__ to write — Pydantic builds one from the annotations.
example_01.pyPydantic
Output
Keyword arguments, and why
Fields are passed by name. Positional arguments are refused on purpose: a model can grow fields over time, and position would silently change meaning when it did.
example_02.pyPydantic
Output
Fields are ordinary attributes
Once built, a model behaves like a normal object: attribute access, methods, everything. It is a class, not a dictionary wearing a hat.
example_03.pyPydantic
Output
Getting the data back out
model_dump() returns a plain dict and model_dump_json() a JSON string. These are how a model leaves your program again.
example_04.pyPydantic
Output
Round trip
Out to JSON and back in again. This is the everyday shape of an API: receive, validate, work, serialise, send.
example_05.pyPydantic
Output
Two models, one shape
Because a model is just a class, the ordinary tools apply. Here two models describe the same subject at different depths — the pattern behind "summary" and "detail" API responses.
example_06.pyPydantic
Output
Creating one
Models take keyword arguments:
m = Module(title="Dot Product", track="maths", minutes=11)
Positional arguments are refused, and the refusal is deliberate rather than an oversight. Models grow. A field added in the middle of a class body would silently change what every positional call meant, and nothing would raise — the values would simply land in the wrong slots. Requiring names makes that class of bug impossible.
When the data is already a dictionary, which it usually is if it came from JSON, there are two ways in:
Module(**data)
Module.model_validate(data)
They do the same work. model_validate reads better in a pipeline, makes it obvious that validation is happening, and does not break if the dictionary happens to contain a key that is not a valid Python identifier.
What you get back
An ordinary object. Attribute access works, methods you define work, and the values have the types the annotations promised:
m.title # "Dot Product"
m.minutes + 1 # 12 - a real int, not a string
That last line is the payoff. Downstream code does not need to check or convert anything, because the conversion already happened at the door.
Models are not dictionaries. m["title"] raises, and asking for a field that does not exist raises AttributeError like any other object. This is a feature: a typo in a dictionary key returns None or a KeyError deep inside a function, whereas a typo in an attribute name is caught immediately and is visible to your editor's autocomplete.
Methods and behaviour
Because a model is a class, you can give it methods:
class Module(BaseModel):
title: str
minutes: int
def slug(self) -> str:
return self.title.lower().replace(" ", "_")
This is worth doing. Logic that depends only on a model's own fields belongs on the model, where it is discoverable and testable, rather than in a loose function three modules away.
Later in this track you will meet @computed_field, which is the version of this that also appears in the serialised output.
Getting the data out
Two methods, and the difference between them is worth getting right early.
model_dump() returns a dictionary of Python objects. A datetime field comes back as a datetime, a Decimal as a Decimal. Use it when the data is staying inside your program — passing to another function, feeding a template, comparing in a test.
model_dump_json() returns a JSON string, converting everything into something JSON can represent. Use it when the data is leaving: an HTTP response, a message queue, a file on disk. It handles the types that plain json.dumps refuses outright, which is a small mercy you will appreciate the first time a datetime appears in a payload.
Both take arguments that shape the output — include, exclude, exclude_none, by_alias — and those get a module of their own in the serialisation tier.
If you have read older tutorials you will have seen .dict() and .json(). Those are the Pydantic v1 names. They still work in v2 but emit deprecation warnings, and a great deal of writing on the internet has not caught up.
The round trip
Put the two directions together and you have the shape of most web services:
wire = original.model_dump_json() # going out
received = Module.model_validate_json(wire) # coming back in
original == received # True
That equality is worth noticing. Models compare by value, not identity, so two separately constructed models with the same contents are equal. In tests this turns a page of field-by-field assertions into one line.
model_validate_json is also the right way to accept JSON. It is tempting to write json.loads followed by model_validate, and it works, but parsing inside Pydantic's Rust core is faster and the errors it produces know where in the document the problem was.
Two models of the same thing
A pattern that arrives quickly in real work: the same subject needs different shapes in different places. A list endpoint returns a title and a duration; a detail endpoint returns everything; a create endpoint accepts everything except the id, which the server assigns.
The instinct is to build one model with a lot of optional fields. Resist it. Optional-everything means the model no longer documents anything — every field might be missing, so no reader can tell what is actually guaranteed.
Separate models say what they mean. ModuleSummary has two required fields and is honest about it. Converting between them is one line, because a dump from one is valid input to the other whenever the fields line up.
Field order, and why it exists
Fields have an order — the order you wrote them — and although you cannot pass them positionally, the order is not cosmetic.
It determines the order of keys in model_dump() and in the generated JSON, which matters when a human reads the output or when something downstream diffs two payloads. It determines the order fields appear in the generated schema, and therefore in API documentation. And it determines validation order, which becomes significant once you write validators that look at previously-validated fields.
The practical advice is to order fields the way you would explain them: identity first, then the important attributes, then the optional extras. It costs nothing and every reader of the output benefits.
Looking at the model itself
A model knows about its own fields, and that introspection is available to you:
Each FieldInfo carries the annotation, whether the field is required, the default if there is one, and any metadata such as a description. This is what the schema generator reads, and it is available to you for the same kind of work — building a form, generating a table header, checking that every field has a description before you ship.
model_fields is defined on the class, not the instance, so you can inspect a model without having any data for it.
Copying and changing
Models are ordinary objects and you can assign to their attributes, but that is often not what you want — particularly if something else is holding a reference to the same object.
model_copy makes a new one:
original = Module(title="Vectors", track="maths", minutes=8)
longer = original.model_copy(update={"minutes": 12})
Two things to know about it. The copy is shallow by default, so a nested model or a list is shared between the two objects; pass deep=True when that matters. And update does not validate the new values — it writes them in directly. If the values came from anywhere untrusted, build a new model instead of copying with an update.
Making a model immutable
By default a model's attributes can be reassigned, and by default that reassignment is not validated:
m.minutes = "not a number" # allowed, and now the field is a string
Two settings fix two different halves of that. validate_assignment=True runs validation on assignment, so the line above raises. frozen=True forbids assignment entirely and makes the model hashable, so it can be a dictionary key or a set member.
class Module(BaseModel):
model_config = ConfigDict(frozen=True)
title: str
Frozen models are worth reaching for more often than people do. A value that arrives from outside, is validated once and then read many times has no business being mutable, and freezing it removes a whole class of "who changed this?" question.
Inheritance
Models inherit, and it works the way you would hope: subclass fields are added to the parent's, and the parent's validators still run.
class ModuleBase(BaseModel):
title: str
minutes: int
class ModuleInDB(ModuleBase):
id: int
created_by: str
This is the standard way to express the family of shapes one concept needs — a base with the common fields, then Create, Update and InDB variants that add or relax what they must. It keeps the shared fields in one place, so a change to the base reaches all of them.
Be careful with one thing: a subclass cannot make an inherited required field optional in a way that reads clearly. Redeclaring title: str = "untitled" works, but a reader now has to check two classes to know what title does. If the variants differ a lot, separate models are kinder than deep inheritance.
What repr gives you
Printing a model prints its fields, which is more useful than the default <Module object at 0x...> and is one of the small things that makes models pleasant in a REPL or a log line.
You can keep a field out of that output with Field(repr=False) — the obvious use being anything secret. A password hash or an API token in a model that gets logged is a genuine incident waiting to happen, and repr=False is the one-word fix.
Note that it only affects the representation. The value is still in model_dump(), so a field that must never leave the process needs exclude=True as well, or a separate output model that simply does not have it.
Two mistakes worth avoiding early
Treating a model like a dictionary. Models do not support m["title"], and the instinct to reach for it usually signals code that is passing models where it should be passing dicts, or the reverse. Decide which side of the boundary a function is on: if it takes untrusted data, it takes a dict and validates it; if it takes validated data, it takes a model and uses attributes.
Putting expensive work in a model. A model is constructed every time data arrives, and anything in a validator runs on that path. A network call, a database lookup or a file read inside a model turns validation into I/O, makes the model impossible to test without mocking, and turns a ValidationError into a timeout. Keep models pure: they check the shape of data using only the data. Rules that need to consult the world belong in the layer that owns the world.
A working shape to copy
Putting the module together, the following is close to what a real model looks like once it has been through a couple of rounds of use:
Frozen, because it arrives from outside and is then only read. Two required fields, because a module without a title or a track is not a module. Two defaults with obvious values. One method, because the slug is derived from the title and belongs next to it.
Nothing exotic, and it already gives you validation, coercion, equality, a readable repr, JSON in both directions and a schema. That ratio — how much you get for how little you wrote — is the reason the library is worth learning properly rather than copying from examples.
What to try next
Change something in the editors above and watch what happens. Add a field to a model and leave it out of the constructor call. Pass minutes as "11" and print its type. Remove a field from the JSON before validating it back.
The next module is precisely about those conversions: which values Pydantic will quietly accept and turn into the type you asked for, and which it will refuse.
Check yourself
0 of 4
Answer without scrolling back up.
Why does a model refuse positional arguments?
Models gain fields over time. If position mattered, inserting a field would reassign every existing positional call's values without raising anything.
What is the difference between `model_dump()` and `model_dump_json()`?
`model_dump()` keeps Python types like `datetime` intact for use inside your program. `model_dump_json()` converts everything to something JSON can carry, for data that is leaving the process.
Two separately created `Module` objects have identical field values. What does `==` return?
Pydantic generates an `__eq__` that compares field values, which is what makes assertions in tests short.
You have a dict from `json.loads`. Which is the better way to build a model from it?
Both `Module(**data)` and `model_validate(data)` work, but `model_validate` states that validation is happening and survives keys that are not valid Python identifiers.
Cheat sheet
Your First BaseModel
That is the entire definition. There is no __init__, no assignment of self.title = title, no validation code. Pydantic reads the annotations when the class is created and builds the machinery from them — a constructor, a validator, a serialiser, an equality method and a readable repr.
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.