Codetail

Article 3 of 8

Modern Data Containers

Stop writing __init__ by hand.

20 min read

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:

Python
1# Pre-3.7: every class you wrote looked like this
2class Point:
3 def __init__(self, x: float, y: float, label: str = "") -> None:
4 self.x = x
5 self.y = y
6 self.label = label
7
8 def __repr__(self) -> str:
9 return f"Point(x={self.x!r}, y={self.y!r}, label={self.label!r})"
10
11 def __eq__(self, other: object) -> bool:
12 if not isinstance(other, Point):
13 return NotImplemented
14 return self.x == other.x and self.y == other.y and self.label == other.label
15
16 def __hash__(self) -> int:
17 return hash((self.x, self.y, self.label))
18
19p1 = 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:

Python
1from dataclasses import dataclass
2
3@dataclass(frozen=True) # frozen=True gives you __hash__ automatically
4class Point:
5 x: float
6 y: float
7 label: str = ""
8
9p1 = Point(1.0, 2.0)
10p2 = Point(1.0, 2.0)
11print(p1) # Point(x=1.0, y=2.0, label='')
12print(p1 == p2) # True
13print(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

Python
1from dataclasses import dataclass
2
3# Without slots: every instance has a __dict__, which is a full dict
4@dataclass
5class PointDict:
6 x: float
7 y: float
8
9# With slots: attributes stored in a compact fixed-size struct
10@dataclass(slots=True)
11class PointSlots:
12 x: float
13 y: float
14
15import sys
16p_dict = PointDict(1.0, 2.0)
17p_slots = PointSlots(1.0, 2.0)
18
19print(sys.getsizeof(p_dict.__dict__)) # ~184 bytes
20print(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

Python
1from dataclasses import dataclass, field
2
3@dataclass
4class Config:
5 host: str = "localhost"
6 port: int = 8080
7 tags: list[str] = field(default_factory=list) # never a plain [] here
8 max_retries: int = field(default=3, repr=False) # hidden in repr
9
10c = 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

Python
1from dataclasses import dataclass
2
3@dataclass
4class Range:
5 start: float
6 end: float
7
8 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})")
11
12 @property
13 def length(self) -> float:
14 return self.end - self.start
15
16r = Range(1.0, 5.0)
17print(r.length) # 4.0
18
19Range(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.

Python
1# Before TypedDict: no type safety on dict fields
2def 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 time
7 }
Python
1from typing import TypedDict
2
3class User(TypedDict):
4 id: int
5 name: str
6 email: str
7
8class UserWithOptionals(TypedDict, total=False): # all keys optional
9 bio: str
10 avatar_url: str
11
12def create_user(data: User) -> User:
13 return {
14 "id": data["id"],
15 "name": data["name"],
16 "email": data["emial"], # type checker catches this typo
17 }
18
19user: 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:

Python
1from typing import NotRequired, Required, TypedDict
2
3class Movie(TypedDict):
4 title: str # required
5 year: int # required
6 director: NotRequired[str] # optional
7 rating: NotRequired[float] # optional
8
9m: Movie = {"title": "Dune", "year": 2021} # valid, optional keys absent
10print(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.

Python
1from typing import NamedTuple
2
3class Coordinate(NamedTuple):
4 lat: float
5 lng: float
6 altitude: float = 0.0
7
8pos = Coordinate(51.5, -0.12)
9
10# Named access
11print(pos.lat) # 51.5
12
13# Tuple unpacking
14lat, lng, alt = pos
15print(lat, lng) # 51.5 -0.12
16
17# Positional indexing
18print(pos[0]) # 51.5
19
20# Hashable, works in sets and dict keys
21visited = {pos}
22print(len(visited)) # 1

The decision

TypeReach for it when
dictkeys are dynamic or unknown at definition time
TypedDictdata comes from JSON or an API and must stay a dict
NamedTupleimmutable record, tuple unpacking needed
@dataclassobject needs methods, validation, or mutable fields
@dataclass(frozen=True)immutable value object that needs to be hashable
Python
1# The same concept across all four, for reference
2
3# dict: flexible but untyped
4user = {"id": 1, "name": "Alice"}
5
6# TypedDict: typed dict, stays a dict at runtime
7from typing import TypedDict
8class User(TypedDict):
9 id: int
10 name: str
11
12# NamedTuple: immutable, tuple-compatible
13from typing import NamedTuple
14class User(NamedTuple):
15 id: int
16 name: str
17
18# dataclass: full class, mutable by default
19from dataclasses import dataclass
20@dataclass
21class User:
22 id: int
23 name: str
24
25# frozen dataclass: immutable, hashable
26@dataclass(frozen=True)
27class User:
28 id: int
29 name: str