Comparison of dataclasses, attrs, cattrs, and pydantic (v2). Companion to Python Type Checkers Comparison; the boundary-validation stance below is the practical shape of Parse, Don’t Validate - Alexis King.
The four aren’t peers. dataclasses and attrs are class-definition tools (generate __init__, __repr__, __eq__, etc.). cattrs is a conversion layer that sits on top of either. pydantic bundles definition + validation + conversion into one opinionated package.
Capability matrix
| dataclasses | attrs | cattrs | pydantic (v2) | |
|---|---|---|---|---|
| Dependency | stdlib | pure-Python, zero deps | needs attrs | heavy (pydantic-core, Rust) |
| Boilerplate generation | init/repr/eq/order/hash | same + evolve, aliases, more knobs | n/a (uses your classes) | yes, via BaseModel or @pydantic.dataclass |
__slots__ | opt-in (3.10+) | default with @define | n/a | opt-in |
| Frozen/immutable | yes | yes | n/a | yes (frozen=True) |
| Runtime type validation | no (__post_init__ only) | opt-in per field | at structure time only | always on |
| Type coercion | no | opt-in converters | yes, when structuring | yes by default (lax), strict mode available |
| To dict/JSON | asdict (dumb, recursive) | asdict (dumb) | unstructure, customizable | model_dump, model_dump_json |
| From dict/JSON | no | no | structure, generics/unions/nested | model_validate, model_validate_json |
| JSON Schema | no | no | no (third-party only) | built-in |
| Custom validators | manual | attrs.validators | hooks per type | @field_validator, @model_validator |
| Works with plain dataclasses / TypedDict / NamedTuple | – | – | yes | partially (TypeAdapter) |
| Instantiation cost | very cheap | very cheap | n/a | noticeably higher (validation runs every time) |
| Parsing JSON cost | – | – | good | very good (Rust) |
| Ecosystem pull | none needed | none needed | preconfigured converters (orjson, msgspec…) | FastAPI, pydantic-settings, most LLM libs |
Pros and cons
- dataclasses: free, everyone knows it, no validation, no parsing. Fine until you need to load data from outside.
- attrs: strictly more capable and slightly faster than dataclasses, cleaner defaults (
@define), but it’s a dependency for a small marginal gain unless you use its validators/converters. - cattrs: keeps domain classes plain and puts (de)serialization in a separate, composable converter; excellent error reporting; but it’s a second library to learn and less discoverable than pydantic.
- pydantic: one-stop shop, great DX, JSON Schema for free, FastAPI-native. Cost: validation on every construction whether you want it or not, a heavy dependency, and it nudges you into putting
BaseModeleverywhere.
When to use what
- Internal data carriers, config objects, ASTs, results → dataclasses (or attrs if it’s already in the project).
- Need validators/converters on internal types but not a serialization framework → attrs.
- Data crosses a boundary (JSON, YAML, DB rows) and domain classes should stay dependency-free → attrs/dataclasses + cattrs.
- Building an HTTP API, need JSON Schema/OpenAPI, or codebase is already pydantic-shaped → pydantic.
- Hot-path serialization where speed dominates → msgspec beats all four on parsing and is stdlib-light.
The expert stance
Validate at the edges, not in the middle. The common failure mode is pydantic models threaded through the whole application: validation cost on every internal construction and a core coupled to a framework. The expert pattern is plain dataclasses/attrs in the core with one conversion layer at the boundary — cattrs if it should be decoupled, pydantic if in a FastAPI world or schema generation matters. Hynek Schlawack (attrs’ author) would say attrs + cattrs; someone shipping a web service on a deadline would say pydantic. Both are right; the mistake is not picking a boundary.