NamedTuple and @dataclass both remove record boilerplate. They do not create the same kind of value.
Core answer
Use NamedTuple when tuple behavior is part of the contract. Use @dataclass when the type should behave like a class record with configurable fields, mutability policy, slots, and generated methods.
# [CURRENT - 3.10-3.14] Works on Python 3.10+from dataclasses import dataclassfrom typing import NamedTupleclass CoordinateTuple(NamedTuple): x: int y: int@dataclass(frozen=True, slots=True)class CoordinateRecord: x: int y: intdef render(point: CoordinateRecord) -> str: return f"{point.x},{point.y}"print(CoordinateTuple(3, 5)[0])print(render(CoordinateRecord(3, 5)))Why this design exists
Tuple-backed named records preserve positional compatibility with existing tuple APIs. Dataclasses from PEP 557 take a different path: annotations describe fields and the decorator generates normal class methods around those fields.
The design choice is visible to callers. Tuple unpacking, hashing defaults, field factories, inheritance, and later evolution differ.
Mechanics and CPython internals
NamedTuple instances store values as tuple elements. Field names live on the class. Dataclasses inspect annotated class fields and generate methods such as __init__, __repr__, and __eq__; storage is regular instance attributes unless slots=True changes the layout.
# [CURRENT - 3.10-3.14] Works on Python 3.10+from dataclasses import dataclass, fieldsfrom typing import NamedTupleclass WirePoint(NamedTuple): x: int y: int@dataclass(slots=True)class MutablePoint: x: int y: intdef describe(point: MutablePoint) -> list[str]: return [field.name for field in fields(point)]wire = WirePoint(1, 2)point = MutablePoint(1, 2)point.x = 9print(tuple(wire), describe(point), point)Complexity and tradeoffs
Both give efficient field access for ordinary code. Tuple-backed records keep positional operations and fixed sequence semantics. Dataclasses trade a slightly broader object model for field configuration, default factories, optional slots, and clearer evolution when position should not be public.
Idiomatic patterns and refactoring
Refactor tuple-position contracts into dataclasses when the record is evolving and index access has become a liability.
# [CURRENT - 3.10-3.14] Works on Python 3.10+from dataclasses import dataclass@dataclass(frozen=True, slots=True)class RetryPolicy: attempts: int timeout_ms: intdef policy_tuple(raw: tuple[int, int]) -> int: return raw[0] * raw[1]def policy_record(policy: RetryPolicy) -> int: return policy.attempts * policy.timeout_mslegacy = (3, 250)modern = RetryPolicy(attempts=3, timeout_ms=250)print(policy_tuple(legacy))print(policy_record(modern))Common mistakes and edge cases
Do not pick NamedTuple if hiding index access matters. Do not assume a dataclass is frozen, hashable, or slotted just because it is concise. Generated methods are public semantics, not comment reduction.
When to use / When NOT to use
Use NamedTuple for tuple-compatible records and dataclasses for class-like domain records.
Do not migrate every tuple blindly; a plain tuple is still appropriate for a small local pair where names add no clarity.