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

Python already has dataclasses, and they look a lot like Pydantic models. You list fields with types, and you get an object with an init method written for you. So a fair question is why you would add a library for the same thing.

The answer is that the two do different jobs. A dataclass stores data in a tidy shape. It does not check it. If you pass a string where an integer belongs, the dataclass accepts it without complaint. Pydantic treats the annotations as rules and enforces them. That single difference decides which one belongs where.

This article compares them honestly. You will see how similar they look at first glance, and what a dataclass really does for you. Then it covers what the extra guarantee costs, in speed and in strictness. Many projects end up using both, and the article explains how to decide which goes where. It closes with a middle option, which is Pydantic's own dataclass decorator, and says when that choice makes sense.

If you already use dataclasses, nothing here asks you to throw them away. The goal is to give you a clear rule for choosing. That rule will save you from two common mistakes. One is adding validation to code that does not need it. The other is trusting data that was never checked. Both are easy to make, and both look reasonable at the time. Seeing the same class written both ways makes the trade-off concrete, and that is how the article is laid out. You can then apply the same test to any new class you write.

Both tools have a fair place in a project.

@dataclass
class Module:
    title: str
    minutes: int

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

Two lines of gap, and both give you a constructor, a readable repr, equality by value and a way to get a dict out. It's fully fair to wonder whether the choice matters.

It matters in just 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. That is the whole choice.

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. Then makes 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") makes just right happily. Nothing is wrong until a thing downstream assumes the annotation was true. And then it fails one spot else, with a traceback pointing at the innocent party.

This isn't a flaw. A dataclass is a code-making convenience. It does what it advertises. It just doesn't do the thing people at times assume it does.

NamedTuple and TypedDict are in the same spot. It's worth saying because TypedDict in given looks like it should be checking a thing. It isn't. It's a description for static type checkers, invisible at runtime.

What that guarantee costs

check is real work: reading a schema, checking each field, at times converting. It isn't free. Any honest compare has to say so.

The timing in the third editor above is worth running. Pydantic 2's core is Rust. 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 a thing.

Read the *ratio* rather than the seconds. all on this page runs on CPython compiled to WebAssembly. This Is a few times slower than a native interpreter. The scaled compare holds. The abs numbers don't transfer to your laptop.

And put the ratio in context. A model that validates in a few microseconds is irrelevant next to a DB query taking milliseconds, or a network call taking hundreds. check cost matters in tight loops over large sets. Almost nowhere else. Choosing a dataclass "for speed" 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 all over is what makes this question feel harder than it's. 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 else. Validate. This is the whole point of the library. The cost is paid once per item arriving.

Inside, past that line, the data has already been checked. Re-checking it at each layer buys nothing. The same values, checked again, will pass again. If you've a hot loop over ten thousand records, a dataclass is a fair interior form, 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 uses, using models throughout is simpler. Simpler is worth more than a microsecond. The two-form pattern earns its keep when profiling has really pointed at check.

The middle option

If you like the dataclass API. Or you've a code full of them. pydantic.dataclasses.dataclass is a drop-in swap that validates:

from pydantic.dataclasses import dataclass

@dataclass
class Module:
    title: str
    minutes: int

Same decorator shape, same dataclasses.fields() introspection. Now minutes="eight" raises. It's 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 check machinery to any annotation, with no model in sight:

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

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

It gets a full module in the output tier. It also handles dumping and schema making for bare types.

A short decision list

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

Reach for **@dataclass** for inner values that never leave the process and never arrive from outside it. And where you've a reason beyond habit.

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

Reach for **TypeAdapter** when the thing you need to validate isn't a class.

The rest of the field

Dataclasses aren't the only option, and the others sit in predictable places.

**attrs** is the library dataclasses were inspired by. It's still ahead in features. Converters, richer checkers, better control over made methods. It does validate, if you ask it to. It's a fair choice for inner classes with complex build. It isn't aimed at the boundary and doesn't make JSON Schema.

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

**TypedDict** shows the shape of a dict for a static type checker. It's worth being clear about this one. At runtime, a TypedDict is a plain dict. There's no class, no checking. No error if a key is missing or the wrong type. It's a comment mypy can read. Where people often want a TypedDict and check, what they want is a model.

**SQLModel** joins Pydantic and SQLAlchemy so one class can be both a table and a checked model. It's convenient and it's a coupling. Your API shape and your DB schema become the same object. This Is fine until they need to differ. This 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. A link to the compiled checker on the class.

A dataclass also uses __dict__ unless you declare slots=True. This Trades the ability to add fields 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 isn't. It's a real reason to use a thing leaner for the interior of a numeric or batch-handling system. It's also a reason not to worry about it before you've measured. "millions of objects" is a exact situation rather than a general one.

Moving between them

Conversion is rote in each direction. This Is what makes the boundary-then-interior pattern hands-on.

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

From dataclass to model: ModuleIn.model_validate(dataclasses.asdict(d)), or model_validate(d) right. Pydantic can read fields off any 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 fields rather than needing 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. It's the single most common reason to reach for it. In Pydantic v1 it was called orm_mode. This 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 doesn't need a model. A tuple, or a NamedTuple if the names help, is fine.

A short-lived inner structure that never leaves the function that built it doesn't need check. Nothing untrusted can reach it.

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

And a value with one field is often just that value. class Slug(BaseModel). Value: str is a wrapper that each caller has to unwrap. If you want a checked 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. Does it ever leave the process?

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

If it leaves the process. As JSON, in a response, in a schema — you want output and docs, and again that is BaseModel.

If neither is true, you've an inner value. The lightest thing that expresses it well is the right answer. That will often be a dataclass, and at times a tuple.

Most objects in most uses are at a boundary. This Is why the honest general recommendation is to use models and stop worrying about it. The notable cases are the exceptions. Now you can recognise them.

A note on migrating an existing codebase

If you arrive at this with a project full of dataclasses, you don't have to choose in one go.

The cheapest first move is to convert only the classes that sit at a boundary. What parses your config, what accepts a request body, what reads a file. Those are where bad data enters. They're often a small fraction of the classes in a project. all else can stay just as it's.

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

What you shouldn't do is convert all to BaseModel mechanically. You'll end up checking objects that were made by your own code from already-checked data, paying for checks that can't fail. The diff will be too large for anyone to review properly.

Pydantic vs Dataclasses: summary

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

The cost of all that is real and small. It's 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've checked.

Choose a dataclass for inner values that never meet the outside world. Choose a model for all else. And when you're truly unsure, choose the model. A slightly over-checked program is a much smaller problem than one where no one can say which values are sure.

Questions people ask about Pydantic vs Dataclasses

What does Pydantic add over a standard dataclass? Runtime validation and coercion, JSON parsing, serialisation and schema generation. A dataclass only stores what you give it and does not check types.

Is a dataclass faster to create? Yes, because it does no validation. For internal data you have already checked, that saving can matter in hot loops.

Can I get validation on a dataclass? Yes, use pydantic.dataclasses.dataclass. It looks like a standard dataclass but validates input on construction.

Can I use Pydantic models and dataclasses together? Yes. A model field can be a dataclass type, and Pydantic validates it. TypeAdapter also works on a plain dataclass.

Which one should I use for API input? A Pydantic model, because data from outside needs checking. Use dataclasses for trusted internal structures.

Does a Pydantic model use more memory? Somewhat, because it stores extra bookkeeping such as model_fields_set. The difference rarely matters unless you hold millions of instances.

Where this leaves you

That is tier one. You can define a model, predict just what it will convert, control what is needed and what is merely nullable, narrow values to your domain with constraints, read the errors when data doesn't fit. 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 options. 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

Compare Pydantic BaseModel with Python dataclasses side by side and see that validation at creation time is the deciding factor.

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.