Pydantic vs Dataclasses

Both give you a class from annotations. Only one checks anything - and the cost of checking is the whole decision.

Overview

They look almost identical

@dataclass
class Module:
    title: str
    minutes: int

class Module(BaseModel):
    title: str
    minutes: int

Two lines of difference, and both give you a constructor, a readable repr, equality by value and a way to get a dictionary out. It is entirely reasonable to wonder whether the choice matters.

It matters in exactly one place: what happens when the data is wrong.

Worth knowing

A @dataclass reads annotations only to decide which fields exist. It never checks a value, ever.
A BaseModel validates and coerces on construction. That costs time, and buys a guarantee.
The honest comparison is not “which is better” but “where am I”. At a boundary, validate. Inside trusted code, do not pay for it twice.
pydantic.dataclasses.dataclass gives the dataclass API with validation — handy when a dataclass already exists and you want checking without a rewrite.
TypeAdapter validates any annotation — List[int], Dict[str, float] — with no model required.
A NamedTuple or TypedDict also checks nothing at runtime. Only Pydantic and libraries like it act on annotations while the program runs.

Pydantic or a Dataclass? A Question Worth Answering Properly

Both build a class from annotations. Only one checks anything, and that is the whole decision.

Side by side

The class bodies are nearly identical. What they do with a wrong value is not.

example_01.pyPydantic
Output

A dataclass will take anything

It generates an __init__ that assigns. It does not look at the annotations at all beyond deciding which fields exist.

example_02.pyPydantic
Output

Speed is the real trade

A dataclass is faster to build because it does nothing. Where the data is already trustworthy, that difference is free.

example_03.pyPydantic
Output

Use both, at different depths

The usual answer is not one or the other. Validate at the boundary, then work with something cheap inside.

example_04.pyPydantic
Output

Pydantic can validate a dataclass too

If you like the dataclass API but want the checking, pydantic.dataclasses.dataclass is a drop-in that validates.

example_05.pyPydantic
Output

Validating anything with TypeAdapter

You do not need a model at all to get validation. TypeAdapter applies the same machinery to any annotation.

example_06.pyPydantic
Output

What a dataclass actually does

@dataclass reads the annotations to find out which fields exist and in what order, and then generates an __init__ that assigns them. That is the extent of its interest in the types. The annotation minutes: int is used to decide that a field called minutes exists. Its being an int is never checked, tested or acted on.

So Module(title=None, minutes=[1, 2, 3], published="perhaps") constructs perfectly happily. Nothing is wrong until something downstream assumes the annotation was true — and then it fails somewhere else, with a traceback pointing at the innocent party.

This is not a flaw. A dataclass is a code-generation convenience, and it does what it advertises. It just does not do the thing people sometimes assume it does.

NamedTuple and TypedDict are in the same position, and it is worth saying because TypedDict in particular looks like it should be validating something. It is not. It is a description for static type checkers, invisible at runtime.

What that guarantee costs

Validation is real work: reading a schema, checking each field, sometimes converting. It is not free, and any honest comparison has to say so.

The timing in the third editor above is worth running. Pydantic 2's core is Rust, so the gap is far smaller than it was in v1, but a dataclass is still faster, for the simple reason that doing nothing is quicker than doing something.

Read the *ratio* rather than the seconds. Everything on this page runs on CPython compiled to WebAssembly, which is several times slower than a native interpreter. The relative comparison holds; the absolute numbers do not transfer to your laptop.

And put the ratio in context. A model that validates in a few microseconds is irrelevant next to a database query taking milliseconds, or a network call taking hundreds. Validation cost matters in tight loops over large collections, and almost nowhere else. Choosing a dataclass "for performance" in a request handler that then makes three SQL queries is optimising the wrong end.

The answer is usually both

The instinct to pick one and use it everywhere is what makes this question feel harder than it is. The better framing is *where in the system am I*.

At the boundary — a request body, a config file, a CSV, another service's response — the data is a guess until proven otherwise. Validate. This is the whole point of the library, and the cost is paid once per item arriving.

Inside, past that line, the data has already been checked. Re-validating it at every layer buys nothing: the same values, checked again, will pass again. If you have a hot loop over ten thousand records, a dataclass is a reasonable interior representation, and converting is one line:

checked = ModuleIn.model_validate(raw)   # validate once
module = Module(**checked.model_dump())  # then go cheap

Be honest about whether you need that, though. For most applications, using models throughout is simpler, and simpler is worth more than a microsecond. The two-representation pattern earns its keep when profiling has actually pointed at validation.

The middle option

If you like the dataclass API — or you have a codebase full of them — pydantic.dataclasses.dataclass is a drop-in replacement that validates:

from pydantic.dataclasses import dataclass

@dataclass
class Module:
    title: str
    minutes: int

Same decorator shape, same dataclasses.fields() introspection, and now minutes="eight" raises. It is a good way to add checking to existing code without rewriting it into models.

The trade is that you get less of Pydantic. model_dump, aliases, custom serialisers and the richer config surface belong to BaseModel. For anything that is going to be serialised, aliased or documented, a real model is the better home.

Validating without a class at all

TypeAdapter is the piece most people meet late and wish they had met early. It applies the whole validation machinery to any annotation, with no model in sight:

TypeAdapter(List[int]).validate_python(["8", "12", 14.0])   # [8, 12, 14]

That is genuinely useful. A JSON array of numbers has no natural model wrapped around it. Neither does a Dict[str, float] of settings, or the argument to a function you want to check at its edge. Building a one-field model to hold a list is a common workaround, and TypeAdapter is the thing it was working around.

It gets a full module in the serialisation tier, because it also handles dumping and schema generation for bare types.

A short decision list

Reach for BaseModel when data crosses a boundary, when you need serialisation or aliases, or when the thing should appear in an API schema. This is most of the time.

Reach for @dataclass for internal values that never leave the process and never arrive from outside it — and where you have a reason beyond habit.

Reach for pydantic.dataclasses.dataclass when you want validation on a dataclass that already exists.

Reach for TypeAdapter when the thing you need to validate is not a class.

The rest of the field

Dataclasses are not the only alternative, and the others sit in predictable places.

attrs is the library dataclasses were inspired by, and it is still ahead in features: converters, richer validators, better control over generated methods. It does validate, if you ask it to, and it is a reasonable choice for internal classes with complex construction. It is not aimed at the boundary and does not generate JSON Schema.

NamedTuple gives you an immutable, tuple-shaped record with named access. It checks nothing at runtime, and its tuple-ness is either the point or a trap depending on whether you wanted something you can unpack and compare positionally.

TypedDict describes the shape of a dictionary for a static type checker. It is worth being clear about this one: at runtime, a TypedDict is a plain dict. There is no class, no checking, and no error if a key is missing or the wrong type. It is a comment mypy can read. Where people usually want a TypedDict and validation, what they want is a model.

SQLModel joins Pydantic and SQLAlchemy so one class can be both a table and a validated model. It is convenient and it is a coupling: your API shape and your database schema become the same object, which is fine until they need to differ, which they eventually do.

Memory, and the shape of an instance

A BaseModel instance stores its data in __dict__ plus some bookkeeping — __pydantic_fields_set__ for what was supplied, and a reference to the compiled validator on the class.

A dataclass also uses __dict__ unless you declare slots=True, which trades the ability to add attributes for a smaller, faster object. A NamedTuple is the smallest of the lot, being a tuple.

For thousands of objects this is invisible. For millions it is not, and it is a real reason to use something leaner for the interior of a numerical or batch-processing system. It is also a reason not to worry about it before you have measured, because "millions of objects" is a specific situation rather than a general one.

Moving between them

Conversion is mechanical in every direction, which is what makes the boundary-then-interior pattern practical.

From model to dataclass: Module(**checked.model_dump()).

From dataclass to model: ModuleIn.model_validate(dataclasses.asdict(d)), or model_validate(d) directly, since Pydantic can read attributes off arbitrary objects when the model sets from_attributes=True.

That last setting deserves a mention of its own. from_attributes=True lets a model validate from any object with matching attributes rather than requiring a dict:

class ModuleOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    title: str
    minutes: int

ModuleOut.model_validate(orm_row)

This is how a model reads an ORM row, and it is the single most common reason to reach for it. In Pydantic v1 it was called orm_mode, which is the name most tutorials still use.

When a model is genuinely too much

A short list of cases where reaching for BaseModel is over-engineering.

A function returning two values does not need a model. A tuple, or a NamedTuple if the names help, is fine.

A short-lived internal structure that never leaves the function that built it does not need validation, because nothing untrusted can reach it.

A constant lookup table is a dict. Wrapping it in a model to get attribute access is a lot of ceremony for a dot.

And a value with one field is usually just that value. class Slug(BaseModel): value: str is a wrapper that every caller has to unwrap. If you want a validated string type, Annotated[str, Field(pattern=...)] gives you that without the box.

The question to actually ask

Not "which is faster" or "which is more modern", but: does anything untrusted reach this object, and does it ever leave the process?

If untrusted data reaches it, you need validation, and BaseModel is the tool built for that.

If it leaves the process — as JSON, in a response, in a schema — you want serialisation and documentation, and again that is BaseModel.

If neither is true, you have an internal value, and the lightest thing that expresses it well is the right answer. That will usually be a dataclass, and occasionally a tuple.

Most objects in most applications are at a boundary, which is why the honest general recommendation is to use models and stop worrying about it. The interesting cases are the exceptions, and now you can recognise them.

A note on migrating an existing codebase

If you arrive at this with a project full of dataclasses, you do not have to choose in one go.

The cheapest first move is to convert only the classes that sit at a boundary — whatever parses your config, whatever accepts a request body, whatever reads a file. Those are where bad data enters, and they are usually a small fraction of the classes in a project. Everything else can stay exactly as it is.

The second move, if you want checking without restructuring, is pydantic.dataclasses.dataclass on the classes that stay. It is a one-line change per class, keeps dataclasses.fields() working for anything that introspects them, and starts raising on wrong types immediately.

What you should not do is convert everything to BaseModel mechanically. You will end up validating objects that were constructed by your own code from already-validated data, paying for checks that cannot fail, and the diff will be too large for anyone to review properly.

Summary

A dataclass generates a class from annotations and never looks at the types again. A model generates a class from annotations and enforces them, converts what can be converted, produces structured errors, serialises in both directions and emits a schema.

The cost of all that is real and small, and it is paid where you decide to pay it. The discipline is the same one this whole tier has been building towards: know where your boundaries are, check there, and trust what you have checked.

Choose a dataclass for internal values that never meet the outside world. Choose a model for everything else. And when you are genuinely unsure, choose the model — a slightly over-validated program is a much smaller problem than one where nobody can say which values are guaranteed.

Where this leaves you

That is tier one. You can define a model, predict exactly what it will convert, control what is required and what is merely nullable, narrow values to your domain with constraints, read the errors when data does not fit, and choose between a model and a plain class on grounds better than taste.

The next tier is about data with shape: models inside models, lists of them, unions that pick between alternatives, and the standard-library types — dates, UUIDs, decimals — that Pydantic already knows how to handle.

Check yourself

0 of 4

Answer without scrolling back up.

  1. What does `@dataclass` do with the annotation `minutes: int`?

  2. Where does validation genuinely earn its cost?

  3. You need to validate a bare `List[int]` with no model around it. What do you use?

  4. What does `pydantic.dataclasses.dataclass` give you?

Cheat sheet

Pydantic vs Dataclasses

Two lines of difference, and both give you a constructor, a readable repr, equality by value and a way to get a dictionary out. It is entirely reasonable to wonder whether the choice matters.

PYDANTIC · vizlearn.in/pydantic/pydantic_vs_dataclasses.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.