Hermes Wiki
Developer/Languages/Python/StandardLibraryIdioms/Fundamentals/dataclasses-vs-namedtuple-vs-typeddict-vs-attrs

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, dataclasses module) 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 for frozen=True for immutability or slots=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.NamedTuple produces 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 a frozen=False escape hatch.
  • typing.TypedDict describes the shape of a plain dict for a type checker's benefit — the object at runtime is a genuinely ordinary dict, with all the mutability, JSON-serializability, and **kwargs-friendliness that implies. Crucially, nothing enforces the shape at runtimeTypedDict is erased at runtime; a checker like mypy/pyright will flag a missing key, but the interpreter itself will not.
  • attrs (third-party, predates and inspired dataclasses) is a superset of what dataclasses does: validators that run at construction, converters that coerce input types, more flexible slotted-class support, and features dataclasses deliberately never added because it aimed to be a smaller stdlib subset of attrs' 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 @dataclass for 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 NamedTuple specifically 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 TypedDict only when the runtime value is already, and should stay, a plain dict — 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/attrs instance immediately after parsing, rather than passing a TypedDict-shaped dict deep into business logic, keeps the "no runtime enforcement" gap narrow instead of open throughout the codebase.
  • Reach for attrs over dataclasses when validators or converters at construction time are a real requirement (e.g., coercing a string into an Enum, rejecting a negative quantity) and Pydantic's heavier validation/serialization machinery is overkill for what is still an internal-only object.
  • Don't use TypedDict expecting 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 attrs by default in a codebase that hasn't needed it yet; it's one more dependency and API surface for something @dataclass already 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

Hermes Wiki