Across three Python services I've shipped, two of them used all three of these libraries at the same time, which is where the trouble started. I have also seen the inevitable sprawl when a codebase reaches for whichever one was top of mind: dataclasses for some internal models, attrs for others, pydantic for everything that touches HTTP, and a handful of plain classes that predate any of them. The result is a model layer where you cannot remember which library a class uses, which means you cannot remember whether the constructor validates input or not, which means subtle bugs.
The argument I want to make is that the three libraries answer three different questions, and once you frame your problem as one of those questions, the choice is mechanical. Most teams pick by popularity ("pydantic is everywhere") or by inertia ("we already have attrs in this codebase"). Both are bad heuristics. The right heuristic is the question being answered.
The three questions
| Question | Right answer |
|---|---|
| I need a struct for internal app data, with no validation, just less typing | dataclasses |
| I need a class with custom slots/converters/validators or rich introspection | attrs |
| I need to validate external input (HTTP body, queue message, config file) | pydantic |
That is the entire decision tree, and the rest of the article is the supporting material for each row. Once you read your situation onto one of those questions, the library follows. "Should I use dataclasses or pydantic" is the wrong framing; the right one is "do I need runtime validation, and if so, of input from where?"
dataclasses: the standard library answer
dataclasses shipped in Python 3.7. The deal: a decorator generates __init__, __repr__, __eq__, and optionally other dunder methods, based on class annotations. Zero runtime validation, zero conversion, zero magic.
The class behaves exactly like a hand-written class with __init__, just shorter. The annotations are documentation that mypy can check; at runtime, order.total = "oops" is fine because Python does not enforce annotations.
This is the right tool when you have internal data passing between layers of your own code. The data has already been validated upstream (by pydantic, by the database, by the protocol you trust), and you just want a clean, comparable, hashable container with autocomplete-friendly fields. The standard library, no extra dependency, no startup cost.
Things dataclasses do well: defaults, default-factories, field(repr=False) for secrets, frozen=True for immutability (with hash support), slots=True for memory savings, comparison operators with order=True, and __post_init__ for derived attributes. If your need fits inside that toolkit, the standard library is enough.
Things dataclasses do not do: validation, type coercion (no "convert this string to int on the way in"), inheritance with required-after-optional fields (a footgun), and any kind of serialisation beyond dataclasses.asdict().
attrs: dataclasses with the power-user knobs
attrs (the library, available as attrs in modern packaging) predates dataclasses and inspired their design. It is a superset of what dataclasses can do, with a richer API.
The two features that earn attrs its place: converters (transform the input value on the way in) and validators (raise on bad input). dataclasses have neither. If you need "the field is a Decimal but I want to accept strings", attrs.field(converter=Decimal) does it. If you need "the field has to be positive", validator=attrs.validators.gt(0) does it.
attrs also has more thoughtful inheritance, better __slots__ ergonomics (with attrs.define, slots are the default), and richer introspection via attrs.fields(). For a class that needs slots, validators, and converters, attrs is shorter and more correct than dataclasses.
The trade: extra dependency, less standard, and the attrs.define (modern API) vs attr.s (legacy API) split that the docs handle but every newcomer trips on. I would not introduce attrs into a codebase for a single class. I would introduce it once five classes need converters or validators.
pydantic: the boundary library
pydantic is in a different category. It is not a data container library, it is a validation and parsing library. The model class doubles as a container, but the killer feature is that the constructor validates and coerces every field on every call.
Note two things. First, total="42.50" is accepted; pydantic coerces the string to a float automatically. Second, currency="USDX" is rejected with a precise error message pointing at the field. This is what "validation at the boundary" looks like in practice.
pydantic v2 (the current generation) is also fast. The validation core is written in Rust, and the throughput on real workloads is in the same league as hand-written validation code, which I did not expect when I first benchmarked it. The performance argument that used to be "pydantic is too slow for hot paths" is largely obsolete.
Use pydantic at every boundary that takes external input: HTTP request bodies (FastAPI uses pydantic natively), queue messages, JSON config files, third-party API responses, environment variables (via pydantic-settings). The model class becomes the typed contract for that boundary, and you stop writing if x is None: raise ValueError("x is required") chains by hand.
Do NOT use pydantic for internal app data that is already validated. The constructor cost is small but real, and the conceptual cost of "every model is a validator" makes refactoring noisier. Internal data that flows between trusted layers should be a dataclass; pydantic models should be the entry and exit shapes.
A worked example: the layered stance
Here is the architecture I have settled on for any non-trivial Python service.
The HTTP layer hands me an OrderRequest; pydantic has already validated quantity is a positive int (with a Field(gt=0) constraint) and discount_percent is in range. The service layer takes that pre-validated input, runs internal logic, and returns an OrderJob dataclass that flows downstream to the database, the queue, the logs.
Mixing the two is intentional: validation at the boundary, plain structs in the interior. I have tried the alternative (pydantic everywhere) and the alternative (dataclasses everywhere), and both produce systems where either the boundary is unsafe or the interior is noisy. The split is the right shape.
attrs slots into this story when a specific class needs converters or validators that are too niche for pydantic and too rich for dataclasses. In the layered stance above, attrs would replace the dataclass if OrderJob.total needed to coerce input strings to Decimal automatically (which is a converter case). Until that need shows up, dataclass is enough.
The decision flow, compressed
This flow has held up across every codebase I have applied it to. The exceptions are usually not really exceptions: someone wants pydantic for internal data because "FastAPI uses pydantic anyway", which is an inertia argument, not a technical one.
What changed in 2024-2026
The advice in this article is steady, but the trade-offs underneath it shifted enough in the last two years that it is worth calling out explicitly.
pydantic v2's Rust core stabilised the perf story. When pydantic v1 was the dominant version, the "pydantic everywhere" failure mode was painful precisely because the library was slow on hot paths and you would actually feel it in p99 latency. v2 moved the validation core into Rust (via pydantic-core), and on most realistic shapes the throughput is now in the same ballpark as hand-written validation. The argument has shifted from "do not use pydantic on hot paths because it is slow" to "do not use pydantic for internal data because it is conceptually wrong", which is a healthier place to argue from. The conceptual argument is the durable one; the perf argument was always going to be eroded by a faster release.
dataclasses-slots matured into a real ergonomic choice. Python 3.10 added @dataclass(slots=True) and the toolchain around it (typing, copy, pickling) caught up over 3.11 and 3.12 to the point where slots-by-default is now a defensible team standard for any internal model that is allocated frequently. This narrows the attrs niche further. Before, "I want slots" was a real reason to reach for attrs; now dataclasses covers that case for free, and attrs is left holding only the converter / validator combo as its differentiator.
msgspec entered the chat for high-throughput JSON. It is not a replacement for any of these three at the application layer, but for stream-processing workloads where you are validating millions of records per second, msgspec is faster than pydantic v2 for the narrow case of JSON-in / typed-struct-out. I have used it in a queue consumer where pydantic was bottlenecking a worker; for the typical web service, pydantic v2 is more than fast enough.
Where I see teams get this wrong
Pydantic everywhere. A team adopts FastAPI, learns pydantic for the request models, then uses pydantic for every internal class because "we already have it". Six months later the model layer is slower than it needs to be (every internal data flow re-validates) and refactors are noisier (every field change requires thinking about validation rules). The way it surfaces in practice is your hot-path latency profile shows 3 to 5 percent of CPU sitting inside pydantic constructors that did not need to run, and your blame-the-flame-graph engineer files a ticket asking why a struct copy is doing schema validation. The fix is to introduce dataclasses for internal data and reserve pydantic for the boundary.
dataclasses for boundary validation. A team without pydantic uses dataclasses to receive HTTP bodies, then writes validation by hand inside __post_init__. The validation is incomplete, inconsistent across endpoints, and slow because the manual checks are not optimised. The way it surfaces in practice is your error responses are inconsistent (some endpoints return a precise field path, others return "bad request" with no detail) and the test suite for input validation balloons because every endpoint reinvents its own check function. The fix is pydantic for any class that takes input from outside.
Mixing all three at the same layer. A team adopts pydantic for new endpoints, has attrs for legacy code, and uses dataclasses in tests. The result is a codebase where you cannot tell at a glance whether a class validates its inputs. The way it surfaces in practice is the refactor blast radius for renaming a single field is enormous, because the same field name lives on a pydantic model, an attrs class, and a dataclass that all represent "the same" entity, and changing one without the others breaks something three layers downstream. The fix is convention: pick the layered stance and apply it consistently.
Boundary in pydantic, interior in dataclasses, attrs only when you need converters
If you are starting a new Python service in 2026, the path that works is: pydantic at the boundary (request models, config, env), dataclasses everywhere else, and attrs only when a specific class needs converters or validators that pydantic and dataclasses cannot do well. Do not try to make one library handle both jobs; the trade-offs are different and the libraries are designed for different jobs. The annoyance of having two libraries (pydantic and dataclasses) is much smaller than the annoyance of either one stretched outside its sweet spot. Three libraries in a single class hierarchy is not a flex; it is a code smell, and the cure is a one-paragraph convention in the team's style guide.
If you want the static-vs-runtime distinction that underlies this whole article (pydantic enforces at runtime, dataclasses do not, mypy is a third thing entirely), my piece on type hints, mypy, and the runtime truth covers exactly that boundary and pairs naturally with this one.
