C.W.K.
Stream
Lesson 06 of 07 · published

dataclass — Boilerplate Removal Done Right

~22 min · dataclass, frozen, kw_only, field, post_init

Level 0Curious
0 XP0/93 lessons0/23 achievements
0/100 XP to next level100 XP to go0% complete

The promise — three lines instead of thirty

Most simple classes need __init__, __repr__, __eq__ — and writing them by hand is repetitive. @dataclass reads the class body's type hints and generates all of those. class Point: x: int; y: int — three lines of declaration, behaving exactly like the verbose hand-written version.

The decorator parameters that actually matter

frozen=True makes instances immutable (no attribute assignment after construction; auto-implements __hash__). kw_only=True makes every field keyword-only at construction (no positional arguments). order=True generates ordering dunders. slots=True (3.10+) uses __slots__ for memory efficiency. The defaults (@dataclass with no parens) are usually right for one-off data containers.

field() — for the cases the simple form can't express

Default values for fields work the same way as default arguments — and have the same mutable-default trap. tags: list[str] = [] shares the list across instances. The fix: tags: list[str] = field(default_factory=list). field() also lets you skip a field in __repr__, exclude it from __eq__, or mark it init=False.

__post_init__ — extra setup after auto-init

Dataclass generates __init__, but sometimes you need extra setup logic — validation, derived fields, etc. Define __post_init__ and it runs at the end of the auto-generated __init__. This is where you put "compute the area from the radius" or "raise if the value is negative."

Pythonic Way: Reach for @dataclass as the default for any value-shaped class — request bodies, config records, parsed events. Reach for plain class when you have meaningful behavior beyond "hold these fields". Reach for typing.NamedTuple when you specifically want tuple semantics (immutable, indexable, unpackable).

Code

Hand-written class vs dataclass — same behavior·python
from dataclasses import dataclass

# Hand-written
class PointManual:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"PointManual(x={self.x}, y={self.y})"

    def __eq__(self, other):
        return isinstance(other, PointManual) and self.x == other.x and self.y == other.y

# Dataclass
@dataclass
class Point:
    x: int
    y: int

p1 = Point(3, 4)
p2 = Point(3, 4)
print(p1)              # Point(x=3, y=4)
print(p1 == p2)        # True
frozen, kw_only, order — the useful flags·python
from dataclasses import dataclass

@dataclass(frozen=True)
class Coordinate:
    lat: float
    lon: float

c = Coordinate(37.5, 127.0)
try:
    c.lat = 40.0          # frozen — assignment raises
except Exception as e:
    print(type(e).__name__, e)

# frozen also gives __hash__ for free
print({c, Coordinate(37.5, 127.0)})    # set with one element (dedup'd)

@dataclass(kw_only=True)
class Config:
    host: str
    port: int = 8000
    debug: bool = False

# Must use keyword arguments
cfg = Config(host="localhost", debug=True)
try:
    Config("localhost", 9000)         # positional rejected
except TypeError as e:
    print(e)
field — defaults, factories, exclusions·python
from dataclasses import dataclass, field

@dataclass
class User:
    name: str
    tags: list[str] = field(default_factory=list)    # fresh list each instance
    password_hash: str = field(repr=False, default="") # excluded from __repr__
    id: int = field(init=False, default=0)            # not in __init__ args

u1 = User("alice")
u1.tags.append("admin")
u2 = User("bob")
print(u1.tags)         # ['admin']
print(u2.tags)         # []          — independent
print(u1)              # User(name='alice', tags=['admin'], id=0)   — password_hash hidden
__post_init__ — derived fields and validation·python
from dataclasses import dataclass, field

@dataclass
class Range:
    start: int
    end: int
    length: int = field(init=False)             # not in __init__, computed

    def __post_init__(self):
        if self.end < self.start:
            raise ValueError("end must be >= start")
        self.length = self.end - self.start

r = Range(start=10, end=25)
print(r)                  # Range(start=10, end=25, length=15)

try:
    Range(start=10, end=5)
except ValueError as e:
    print(e)
When NOT to dataclass — when you have real behavior·python
# A dataclass is for VALUES (data with auto __init__/__repr__/__eq__).
# When the class has rich behavior, write a plain class.

class Connection:               # behavior-heavy — not a great dataclass
    def __init__(self, host):
        self.host = host
        self._socket = None

    def connect(self):
        # complex setup
        self._socket = f"<socket to {self.host}>"

    def send(self, data):
        if self._socket is None:
            raise RuntimeError("not connected")
        return f"sent {data!r} via {self._socket}"

# Trying to dataclass this would force you to use post_init for setup
# and add a bunch of field(init=False) — clearer to write the class directly.

External links

Exercise

Build a @dataclass Order with fields id: str, items: list[str], total: float = 0.0. Use field(default_factory=list) for items. Add a __post_init__ that recomputes total from a price lookup dict you pass in (hint: store the price dict as a class-level constant on the dataclass — PRICES: ClassVar[dict] = {...}). Then make a second variant FrozenOrder(frozen=True, kw_only=True) and demonstrate immutability + that you can put it in a set.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.