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
from dataclasses import dataclass, asdict
from pydantic import BaseModel, ValidationError
@dataclass
class ModuleDC:
title: str
minutes: int
class ModuleBM(BaseModel):
title: str
minutes: int
d = ModuleDC(title="Norms", minutes="eight")
print("dataclass accepted :", d)
print(" minutes is a :", type(d.minutes).__name__)
try:
ModuleBM(title="Norms", minutes="eight")
except ValidationError as e:
print("pydantic refused :", e.errors()[0]["msg"])
print()
print("both convert to dicts:")
print(" ", asdict(d))
print(" ", ModuleBM(title="Norms", minutes=8).model_dump())
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
from dataclasses import dataclass
@dataclass
class Module:
title: str
minutes: int
published: bool
m = Module(title=None, minutes=[1, 2, 3], published="perhaps")
print("constructed happily:", m)
print()
print("title :", type(m.title).__name__)
print("minutes :", type(m.minutes).__name__)
print("published :", type(m.published).__name__)
# Nothing is wrong until something downstream assumes otherwise:
try:
print(m.minutes / 2)
except TypeError as e:
print()
print("and here it finally breaks:", e)
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
import time
from dataclasses import dataclass
from pydantic import BaseModel
@dataclass
class PointDC:
x: float
y: float
class PointBM(BaseModel):
x: float
y: float
N = 20000
t = time.perf_counter()
for i in range(N):
PointDC(x=1.0, y=2.0)
dc = time.perf_counter() - t
t = time.perf_counter()
for i in range(N):
PointBM(x=1.0, y=2.0)
bm = time.perf_counter() - t
print("dataclass : %.3f s" % dc)
print("pydantic : %.3f s" % bm)
print("ratio : %.1fx" % (bm / dc))
print()
print("Everything here runs slower than a real interpreter, so the")
print("ratio is the number that transfers, not the seconds.")
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
from dataclasses import dataclass
from pydantic import BaseModel
# The boundary: whatever arrived, checked once.
class ModuleIn(BaseModel):
title: str
minutes: int
# The interior: trusted, cheap, used in hot loops.
@dataclass
class Module:
title: str
minutes: int
def reading_time(self) -> str:
return "about %d min" % round(self.minutes * 1.2)
raw = {"title": "Eigenvalues", "minutes": "14"} # note the string
checked = ModuleIn.model_validate(raw) # validate ONCE
module = Module(**checked.model_dump()) # then go cheap
print("validated :", checked)
print("internal :", module)
print("derived :", module.reading_time())
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
from pydantic.dataclasses import dataclass
from pydantic import ValidationError
@dataclass
class Module:
title: str
minutes: int
print("coerces :", Module(title="Norms", minutes="8"))
try:
Module(title="Norms", minutes="eight")
except ValidationError as e:
print("refuses :", e.errors()[0]["msg"])
# It is still a real dataclass:
import dataclasses
print()
print("is a dataclass:", dataclasses.is_dataclass(Module))
print("fields :", [f.name for f in dataclasses.fields(Module)])
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
from typing import Dict, List
from pydantic import TypeAdapter, ValidationError
minutes = TypeAdapter(List[int])
print("list :", minutes.validate_python(["8", "12", 14.0]))
lookup = TypeAdapter(Dict[str, float])
print("dict :", lookup.validate_python({"maths": "4.5", "python": 4}))
try:
minutes.validate_python(["8", "soon"])
except ValidationError as e:
err = e.errors()[0]
print("bad :", err["loc"], err["msg"])
# Useful for a function argument, a config blob, or a JSON array
# that has no natural model around it.
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:
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 TypedDictis 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.
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
Compare Pydantic BaseModel with Python dataclasses side by side and see that validation at creation time is the deciding factor.
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.