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 BaseModel is the class you inherit from to get everything Pydantic offers. You list the fields you expect, give each one a type, and the class does the rest. It checks the data when you create an object, converts what it can, and refuses what it cannot.
This is the first model you will write, so the article stays small on purpose. You will see how little code it takes to define one, what happens when you create an instance from clean data, and what the object lets you do afterwards. Fields become attributes. The object knows how to describe itself, compare itself to another, and copy itself.
The article walks through creating a model, reading what comes back, and the methods every model carries. It then shows how to get the data out again as a dictionary or as JSON, and how that round trip behaves. It ends with two models describing the same thing, which shows why you sometimes want one shape for input and another for output. Read it before anything else in the track, because every later article builds on this class.
If you have never used the library, treat this as the map for everything else. The ideas here come back in every later article, so a few minutes now saves confusion later. No prior knowledge of validation is needed.
A Pydantic model is a class that takes from 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 meaning. There's no __init__, no set of self.title = title, no check code. Pydantic reads the annotations when the class is created and builds the machinery from them. A constructor, a checker, 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 equal it's four. The param, the set, the check. The line in to_dict. Those four drift apart over time. 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
from pydantic import BaseModel
class Module(BaseModel):
title: str
track: str
minutes: int
m = Module(title="Dot Product", track="maths", minutes=11)
print(m)
print()
print("one field :", m.title)
print("another :", m.minutes)
print("its type :", type(m.minutes).__name__)
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
from pydantic import BaseModel
class Module(BaseModel):
title: str
track: str
minutes: int
# This is the way.
print(Module(title="Norms", track="maths", minutes=8))
# This is not:
try:
Module("Norms", "maths", 8)
except TypeError as e:
print("TypeError:", e)
# A dict works too, which is what you usually have from JSON.
data = {"title": "Norms", "track": "maths", "minutes": 8}
print(Module(**data))
print(Module.model_validate(data)) # the explicit version
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
from pydantic import BaseModel
class Module(BaseModel):
title: str
track: str
minutes: int
# Methods work exactly as they do on any class.
def slug(self) -> str:
return self.title.lower().replace(" ", "_")
def is_long(self) -> bool:
return self.minutes > 10
m = Module(title="Matrix Multiplication", track="maths", minutes=15)
print("title :", m.title)
print("slug :", m.slug())
print("long? :", m.is_long())
# Reading a field that does not exist fails like any attribute would.
try:
print(m.author)
except AttributeError as e:
print("AttributeError:", e)
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
from pydantic import BaseModel
class Module(BaseModel):
title: str
track: str
minutes: int
m = Module(title="Eigenvalues", track="maths", minutes=14)
d = m.model_dump()
print("dict :", d)
print("a key :", d["track"])
print()
print("json :", m.model_dump_json())
print("pretty :")
print(m.model_dump_json(indent=2))
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
from pydantic import BaseModel
class Module(BaseModel):
title: str
track: str
minutes: int
original = Module(title="Projections", track="maths", minutes=9)
wire = original.model_dump_json()
print("sent :", wire)
received = Module.model_validate_json(wire)
print("received :", received)
print()
print("equal? :", original == received)
# Models compare by value, not identity - handy in tests.
print("same obj?:", original is received)
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
from pydantic import BaseModel
class ModuleSummary(BaseModel):
title: str
minutes: int
class ModuleDetail(BaseModel):
title: str
minutes: int
track: str
description: str
full = ModuleDetail(
title="The Chain Rule",
minutes=13,
track="maths",
description="How derivatives compose, and why backpropagation is this rule.",
)
# Build the smaller one from the bigger one's data.
summary = ModuleSummary.model_validate(full.model_dump())
print("detail :", full.model_dump())
print()
print("summary :", summary.model_dump())
Output
Creating one
Models take keyword args:
m = Module(title="Dot Product", track="maths", minutes=11)
Spot-based args 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 each spot-based call meant, and nothing would raise. The values would simply land in the wrong slots. needing names makes that class of bug hopeless.
When the data is already a dict. This It often 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 check is happening. Doesn't break if the dict happens to hold a key that isn't a valid Python identifier.
What you get back
An plain object. Field access works, methods you define work. 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 doesn't need to check or convert anything. The conversion already happened at the door.
Models aren't dicts. m["title"] raises, and asking for a field that doesn't exist raises AttributeError like any other object. This is a feature. A typo in a dict key returns None or a KeyError deep inside a function, whereas a typo in an field name is caught at once 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. Here It's discoverable and testable, rather than in a loose function three modules away.
Later in this track you'll meet @computed_field. This Is the version of this that also appears in the serialised output.
Getting the data out
Two methods, and the gap between them is worth getting right early.
model_dump() returns a dict 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, checking in a test.
model_dump_json() returns a JSON string, converting all into a thing 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. This Is a small mercy you'll appreciate the first time a datetime appears in a payload.
Both take args that shape the output. include, exclude, exclude_none, by_alias. And those get a module of their own in the output tier.
If you've read older tutorials you'll have seen .dict() and .json(). Those are the Pydantic v1 names. They still work in v2 but emit deprecation warnings. A great deal of writing on the internet hasn't caught up.
The round trip
Put the two directions together and you've 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. Two apart made 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's tempting to write json.loads followed by model_validate. It works. Parsing inside Pydantic's Rust core is faster and the errors it makes know where in the doc the problem was.
Two models of the same thing
A pattern that arrives quickly in real work. The same subject needs other shapes in other places. A list endpoint returns a title and a duration. A detail endpoint returns all. A create endpoint accepts all except the id. This The server assigns.
The instinct is to build one model with a lot of optional fields. Resist it. Optional-all means the model no longer docs anything. each field might be missing. No reader can tell what is really sure.
split models say what they mean. ModuleSummary has two needed fields and is honest about it. Converting between them is one line. A dump from one is valid input to the other when the fields line up.
Field order, and why it exists
Fields have an order — the order you wrote them — and although you can't pass them positionally, the order isn't cosmetic.
It finds the order of keys in model_dump() and in the made JSON. This Matters when a human reads the output or when a thing downstream diffs two payloads. It finds the order fields appear in the made schema. So in API docs. And it finds check order. This Turns large once you write checkers that look at before-checked fields.
The hands-on advice is to order fields the way you'd explain them. Identity first, then the key fields, then the optional extras. It costs nothing and each reader of the output gains.
Looking at the model itself
A model knows about its own fields, and that introspection is on hand to you:
Each FieldInfo carries the annotation, whether the field is needed, the default if there's one. Any metadata such as a description. This is what the schema gen reads. It's on hand to you for the same kind of work. Building a form, making a table header, checking that each field has a description before you ship.
model_fields is defined on the class, not the instance. You can inspect a model without having any data for it.
Copying and changing
Models are plain objects and you can assign to their fields. That is often not what you want. Above all if a thing else is holding a link 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. 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 right. 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 fields can be reassigned, and by default that reassignment isn't checked:
m.minutes = "not a number" # allowed, and now the field is a string
Two settings fix two other halves of that. validate_assignment=True runs check on set. The line above raises. frozen=True forbids set fully and makes the model hashable. So It can be a dict 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 checked once and then read many times has no business being mutable. Freezing it drops a whole class of "who changed this?" question.
Inheritance
Models inherit, and it works the way you'd hope. Subclass fields are added to the parent's, and the parent's checkers 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. A change to the base reaches all of them.
Be careful with one thing: a subclass can't make an from parent needed field optional in a way that reads clearly. Redeclaring title: str = "untitled" works. A reader now has to check two classes to know what title does. If the variants differ a lot, split models are kinder than deep class chain.
What repr gives you
Printing a model prints its fields. This Is more handy 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. repr=False is the one-word fix.
Note that it only affects the form. The value is still in model_dump(). A field that must never leave the process needs exclude=True as well, or a split output model that simply doesn't have it.
Two mistakes worth avoiding early
Treating a model like a dict. Models don't support m["title"]. The instinct to reach for it often 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 checked data, it takes a model and uses fields.
Putting costly work in a model. A model is made each time data arrives. Anything in a checker runs on that path. A network call, a DB lookup or a file read inside a model turns check into I/O, makes the model hopeless to test without mocking. 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, these 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 needed fields, because a module without a title or a track isn't 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 check, 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.
Questions people ask about Your First BaseModel
How do I define a model? Subclass BaseModel and add annotated fields, for example title: str and minutes: int.
How do I create an instance? Call the class with keyword arguments, or use Model.model_validate(data) with a dict. Positional arguments are not accepted.
How do I turn a model back into a dict or JSON? Use model_dump() for a dict and model_dump_json() for a JSON string.
Are model instances mutable? Yes by default. Set frozen=True in model_config to make them immutable.
How do I copy a model with one change? Use m.model_copy(update={"minutes": 12}). The update values are not validated.
Can a required field come after one with a default? Yes. Unlike dataclasses, Pydantic allows it, and fields keep the order you wrote them for printing and dumping.
What to try next
Change a thing 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. Drop a field from the JSON before checking it back.
The next module is just 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
Define a Pydantic BaseModel, create an instance from raw data, trigger a validation error on purpose, and export the result as a dict.
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.