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:
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 TypedDictis 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.
What does `@dataclass` do with the annotation `minutes: int`?
A dataclass reads annotations to discover the fields and generate an `__init__` that assigns them. The types are never checked at runtime.
Where does validation genuinely earn its cost?
Data crossing into your code is a guess until checked. Past that line it has already been proven, and re-checking the same values buys nothing.
You need to validate a bare `List[int]` with no model around it. What do you use?
`TypeAdapter(List[int])` applies the full machinery to any annotation. The wrapper model is the workaround people use before they discover it.
What does `pydantic.dataclasses.dataclass` give you?
It is a drop-in that keeps dataclass introspection while validating on construction - useful for adding checks to existing dataclasses without rewriting them as models.
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.
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.