Dataclasses vs. NamedTuple vs. TypedDict vs. attrs: Picking a Structured-Data Tool in Python
Concept
Python offers at least four stdlib-or-near-stdlib ways to define "a bag of named fields," and reaching for the wrong one is a recurring source of either boilerplate or bugs. They aren't interchangeable — each optimizes for a different shape of problem:
@dataclass(stdlib,dataclassesmodule) generates__init__,__repr__, and__eq__for a real class from a set of annotated fields. It's a normal class underneath — you can add methods, override behavior, subclass it, and reach forfrozen=Truefor immutability orslots=True(3.10+) to drop per-instance__dict__overhead. It does not validate anything at runtime; type annotations are purely for static checkers and documentation.typing.NamedTupleproduces an actual tuple subclass — immutable, hashable, unpackable (x, y = point), comparable by value out of the box, usable as a dict key or set member. The tradeoff for that tuple heritage is that it's fundamentally flat and immutable; there's no clean way to bolt on mutable state or afrozen=Falseescape hatch.typing.TypedDictdescribes the shape of a plaindictfor a type checker's benefit — the object at runtime is a genuinely ordinarydict, with all the mutability, JSON-serializability, and**kwargs-friendliness that implies. Crucially, nothing enforces the shape at runtime —TypedDictis erased at runtime; a checker like mypy/pyright will flag a missing key, but the interpreter itself will not.attrs(third-party, predates and inspireddataclasses) is a superset of whatdataclassesdoes: validators that run at construction, converters that coerce input types, more flexible slotted-class support, and featuresdataclassesdeliberately never added because it aimed to be a smaller stdlib subset ofattrs' functionality.
Tradeoffs
| Tool | Runtime behavior | Best for | Cost |
|---|---|---|---|
@dataclass |
Real class, no validation | General-purpose internal data containers with methods/behavior | No runtime guarantees — a caller can still construct one with wrong types; __post_init__ validation must be hand-written |
NamedTuple |
Immutable tuple subclass | Small, immutable, hashable records (config values, coordinate pairs, function return bundles) | No mutation, no easy partial-update pattern (._replace() exists but is clunkier than dataclass field reassignment); nesting/inheritance is awkward |
TypedDict |
Plain dict, shape-checked statically only |
Typing external-facing dict payloads (JSON API bodies, **kwargs) without changing runtime representation |
Zero runtime enforcement — a bug that produces a dict missing a required key is invisible until something downstream KeyErrors |
attrs |
Real class, opt-in runtime validators/converters | Data that needs actual runtime validation or type coercion at construction time, without adopting a heavier framework | External dependency; team has to learn a second (richer, older) API alongside stdlib dataclasses |
A fifth option worth naming even though it's outside this note's scope: Pydantic, which adds full runtime validation and JSON (de)serialization on top of a dataclass-like API — the right choice when the data is genuinely untrusted input (a request body) rather than an internal container. If the project already uses Pydantic for its I/O boundary (as covered in TypingAndStaticAnalysis), don't also reach for attrs for the same job — pick one runtime-validating tool, not two.
When to use / when not to
- Default to
@dataclassfor internal, in-process data containers that need methods, mutability, or subclassing — it's stdlib, zero dependencies, and every Python engineer already knows the decorator. - Reach for
NamedTuplespecifically when hashability or tuple-unpacking matters (a dict key, a set member,for x, y in points:) or when the value is genuinely a fixed, small, immutable record — not as a default replacement for@dataclass(frozen=True), which covers the same immutability guarantee with a richer class underneath. - Use
TypedDictonly when the runtime value is already, and should stay, a plaindict— typically because it crosses a JSON boundary (an API response you're about to type-annotate) or is being merged with**kwargs. Converting that boundary data into a@dataclass/attrsinstance immediately after parsing, rather than passing aTypedDict-shaped dict deep into business logic, keeps the "no runtime enforcement" gap narrow instead of open throughout the codebase. - Reach for
attrsoverdataclasseswhen validators or converters at construction time are a real requirement (e.g., coercing a string into anEnum, rejecting a negative quantity) and Pydantic's heavier validation/serialization machinery is overkill for what is still an internal-only object. - Don't use
TypedDictexpecting it to catch a bad payload at runtime — that's what Pydantic (or a hand-written__post_init__check on a dataclass) is for. - Don't reach for
attrsby default in a codebase that hasn't needed it yet; it's one more dependency and API surface for something@dataclassalready covers for the un-validated case.
Common pitfall
Treating TypedDict as if it were a validating type, then being surprised when malformed data reaches deep into the codebase before failing. Because a TypedDict-typed value is a completely ordinary dict at runtime, a static checker only catches shape mismatches at call sites it can see — code that builds the dict dynamically (from a loop, from json.loads(), from merging two dicts) routinely defeats that checking entirely, and the interpreter enforces nothing. The fix isn't "add more type annotations" — it's converting untrusted dict-shaped data into a real validated object (Pydantic, or a dataclass with explicit __post_init__ checks) at the boundary where it enters the system, and only using TypedDict for data that's genuinely still a passthrough dict.
Engineering Lens
The decision isn't "which is the modern/best one" — all four are actively maintained, current idioms, each solving a different problem. The actual design question is where the runtime/static-only line should sit for a given piece of data: NamedTuple and TypedDict intentionally add zero runtime behavior on top of a builtin type (tuple/dict), which is exactly right for data that's genuinely just passing through in that shape; @dataclass and attrs add a real class identity, which is right the moment the data needs methods, invariants, or to be treated as more than its fields. Picking based on "which import is shorter" instead of that distinction is how a codebase ends up with TypedDict payloads silently missing required keys three layers deep in business logic that assumed a dataclass's guarantees.
Sources
- Dataclasses vs. Pydantic vs. TypedDict vs. NamedTuple in Python
- Why not… — attrs documentation
- Data Classes vs NamedTuple vs TypedDict in Python