The boilerplate tax
A simple data-holding class in pre-3.7 Python cost you roughly twenty lines before you could do anything useful with it. Here is a point with two coordinates:
1# Pre-3.7: every class you wrote looked like this2class Point:3 def __init__(self, x: float, y: float, label: str = "") -> None:4 self.x = x5 self.y = y6 self.label = label78 def __repr__(self) -> str:9 return f"Point(x={self.x!r}, y={self.y!r}, label={self.label!r})"1011 def __eq__(self, other: object) -> bool:12 if not isinstance(other, Point):13 return NotImplemented14 return self.x == other.x and self.y == other.y and self.label == other.label1516 def __hash__(self) -> int:17 return hash((self.x, self.y, self.label))1819p1 = Point(1.0, 2.0)20p2 = Point(1.0, 2.0)21print(p1) # Point(x=1.0, y=2.0, label='')22print(p1 == p2) # True
The same class as a dataclass:
1from dataclasses import dataclass23@dataclass(frozen=True) # frozen=True gives you __hash__ automatically4class Point:5 x: float6 y: float7 label: str = ""89p1 = Point(1.0, 2.0)10p2 = Point(1.0, 2.0)11print(p1) # Point(x=1.0, y=2.0, label='')12print(p1 == p2) # True13print(hash(p1)) # works, because frozen=True
@dataclass generates __init__, __repr__, and __eq__ by default. Add frozen=True and you also get __hash__ and immutability. The fields are declared as class-level annotations, not assignments inside __init__. Defaults go directly on the annotation line.
dataclass options: slots, order, field(), __post_init__
slots=True (3.10): leaner memory, faster attribute access
1from dataclasses import dataclass23# Without slots: every instance has a __dict__, which is a full dict4@dataclass5class PointDict:6 x: float7 y: float89# With slots: attributes stored in a compact fixed-size struct10@dataclass(slots=True)11class PointSlots:12 x: float13 y: float1415import sys16p_dict = PointDict(1.0, 2.0)17p_slots = PointSlots(1.0, 2.0)1819print(sys.getsizeof(p_dict.__dict__)) # ~184 bytes20print(hasattr(p_slots, "__dict__")) # False -- no dict at all
Use slots=True when you create many instances (data pipelines, game objects, simulation entities). The trade-off: you cannot add arbitrary attributes at runtime, and slots do not work with multiple inheritance from non-slot parents.
field(): mutable defaults and metadata
1from dataclasses import dataclass, field23@dataclass4class Config:5 host: str = "localhost"6 port: int = 80807 tags: list[str] = field(default_factory=list) # never a plain [] here8 max_retries: int = field(default=3, repr=False) # hidden in repr910c = Config()11print(c) # Config(host='localhost', port=8080, tags=[])
Rule: any mutable default (list, dict, set) must use field(default_factory=...). Writing tags: list[str] = [] is a ValueError at class definition time. Dataclass catches this mistake for you.
__post_init__: validation after generation
1from dataclasses import dataclass23@dataclass4class Range:5 start: float6 end: float78 def __post_init__(self) -> None:9 if self.end <= self.start:10 raise ValueError(f"end ({self.end}) must be greater than start ({self.start})")1112 @property13 def length(self) -> float:14 return self.end - self.start1516r = Range(1.0, 5.0)17print(r.length) # 4.01819Range(5.0, 1.0) # ValueError
TypedDict: typed dictionaries without class overhead
API responses, database rows, and config blobs often arrive as plain dicts. You could type them as dict[str, Any] and lose all type safety, or convert them to dataclasses and pay the conversion cost. TypedDict is the third option: a typed dict that stays a dict at runtime.
1# Before TypedDict: no type safety on dict fields2def create_user(data: dict) -> dict:3 return {4 "id": data["id"],5 "name": data["name"],6 "email": data["emial"], # typo in key: silent at type-check time7 }
1from typing import TypedDict23class User(TypedDict):4 id: int5 name: str6 email: str78class UserWithOptionals(TypedDict, total=False): # all keys optional9 bio: str10 avatar_url: str1112def create_user(data: User) -> User:13 return {14 "id": data["id"],15 "name": data["name"],16 "email": data["emial"], # type checker catches this typo17 }1819user: User = {"id": 1, "name": "Alice", "email": "alice@example.com"}20print(user["name"]) # Alice
Required and NotRequired (3.11)
Before 3.11, making some keys optional and some required required two separate TypedDicts and inheritance. Now you can mix them in one definition:
1from typing import NotRequired, Required, TypedDict23class Movie(TypedDict):4 title: str # required5 year: int # required6 director: NotRequired[str] # optional7 rating: NotRequired[float] # optional89m: Movie = {"title": "Dune", "year": 2021} # valid, optional keys absent10print(m["title"]) # Dune
Choosing between dict, TypedDict, NamedTuple, and dataclass
Four containers, and each is the right answer for a specific situation. Picking the wrong one is not a disaster, but it creates friction.
NamedTuple
Use when you need tuple semantics: positional indexing, unpacking, immutability. Common for return values where you want both named access and tuple unpacking.
1from typing import NamedTuple23class Coordinate(NamedTuple):4 lat: float5 lng: float6 altitude: float = 0.078pos = Coordinate(51.5, -0.12)910# Named access11print(pos.lat) # 51.51213# Tuple unpacking14lat, lng, alt = pos15print(lat, lng) # 51.5 -0.121617# Positional indexing18print(pos[0]) # 51.51920# Hashable, works in sets and dict keys21visited = {pos}22print(len(visited)) # 1
The decision
| Type | Reach for it when |
|---|---|
| dict | keys are dynamic or unknown at definition time |
| TypedDict | data comes from JSON or an API and must stay a dict |
| NamedTuple | immutable record, tuple unpacking needed |
| @dataclass | object needs methods, validation, or mutable fields |
| @dataclass(frozen=True) | immutable value object that needs to be hashable |
1# The same concept across all four, for reference23# dict: flexible but untyped4user = {"id": 1, "name": "Alice"}56# TypedDict: typed dict, stays a dict at runtime7from typing import TypedDict8class User(TypedDict):9 id: int10 name: str1112# NamedTuple: immutable, tuple-compatible13from typing import NamedTuple14class User(NamedTuple):15 id: int16 name: str1718# dataclass: full class, mutable by default19from dataclasses import dataclass20@dataclass21class User:22 id: int23 name: str2425# frozen dataclass: immutable, hashable26@dataclass(frozen=True)27class User:28 id: int29 name: str