Your First BaseModel

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:

Module.model_fields          # {'title': FieldInfo(...), 'minutes': FieldInfo(...)}
list(Module.model_fields)    # ['title', 'minutes']

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:

class Module(BaseModel):
    model_config = ConfigDict(frozen=True)

    title: str
    track: str
    minutes: int = 10
    published: bool = False

    def slug(self) -> str:
        return self.title.lower().replace(" ", "_")

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.

  1. Why does a model refuse positional arguments?

  2. What is the difference between `model_dump()` and `model_dump_json()`?

  3. Two separately created `Module` objects have identical field values. What does `==` return?

  4. You have a dict from `json.loads`. Which is the better way to build a model from it?

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.

PYDANTIC · vizlearn.in/pydantic/your_first_basemodel.html

About the author

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.