Classes, dataclasses, and __slots__: generated boilerplate and the per-instance dict
@dataclass generates __init__/__repr__/__eq__ from annotations; eq without frozen sets __hash__ = None; mutable defaults need default_factory. __slots__ drops the instance __dict__: ~100 B saved per object, faster access, but no dynamic attrs and tricky multiple inheritance.
The ETL service loaded a 40-million-row reference table into memory on startup — one small Candle object per row, five float fields each. On paper: 40 M × 5 × 8 bytes ≈ 1.6 GB of payload. In production the pod needed 8 GB and OOM-killed at peak. The gap was not the floats; it was the object plumbing. Every plain-Python instance dragged a __dict__ along — the dictionary that makes obj.anything = whatever possible — costing roughly a hundred bytes of overhead per object even when you never add a dynamic attribute. One line, __slots__ = ("ts", "o", "h", "l", "c"), replaced the dict with fixed C-level slots: memory dropped to under 3 GB, attribute reads got measurably faster, and the pod stopped dying. The follow-up incident, two sprints later, was the other half of the lesson: a debugging hack that did candle.is_synthetic = True now raised AttributeError — slots had revoked the very flexibility everyone had been silently depending on.
By the end you will know which one line cuts that 8 GB pod to 3 GB — and which follow-up surprise two sprints later proves the tradeoff is real.
What a class statement actually does
class Candle: is runtime code, not a declaration: Python executes the body in a fresh namespace, then calls type(name, bases, namespace) to manufacture the class object. Methods are just functions that landed in that namespace; Candle.__dict__ is where lesson 2’s attribute lookup finds them. Two consequences matter daily. First, class-body assignments run once — a mutable class attribute (cache = {}) is shared by every instance, the classic shared-state bug. Second, since the class is built by calling type, anything can hook that call — decorators like @dataclass post-process the finished class, which is exactly how it adds methods without metaclass magic.
@dataclass mechanics: what is generated, and the eq/hash interplay
@dataclass reads the class annotations and synthesizes the boilerplate: __init__ (parameters in annotation order), __repr__, and __eq__ comparing the field tuple. The defaults you must actually know:
from dataclasses import dataclass, field
@dataclass
class Order:
id: int
tags: list[str] = field(default_factory=list) # NOT tags: list = []
note: str = "" # plain defaults fine for immutables
@dataclass(frozen=True) # __setattr__ raises; __hash__ generated
class Money:
amount: int
currency: str- Mutable defaults:
tags: list = []raisesValueErrorat class-creation time — dataclasses forcedefault_factorybecause a shared list default is the same once-executed-class-body bug as above. (Plain classes let you make this mistake silently; dataclasses turned it into an error.) - eq/hash interplay — lesson 1’s contract, now in generator form: default
eq=Truegives you__eq__and therefore__hash__ = None; instances are unhashable.frozen=Truemakes instances immutable (assignment raisesFrozenInstanceError) and, witheq=True, generates a field-based__hash__— frozen dataclasses are safe dict keys. The escape hatcheq=True, unsafe_hash=Truehashes a mutable class and is named “unsafe” for the lesson-1 reason: mutate a hashed field and the object is lost in its bucket. order=Trueadds__lt__/__le__/… comparing field tuples — handy forsorted(), wrong if field order is not your semantic order.
You declare `@dataclass class Point: x: int; y: int` and use Point instances as dict keys for a spatial cache. What happens?
slots: trading the dict for memory and speed
If you are loading millions of uniform objects and your process RSS is suspiciously higher than the raw payload math suggests, the first question to ask yourself is: does every instance carry a __dict__ it never needs?
Declaring __slots__ = ("ts", "o", "h", "l", "c") tells class creation: do not give instances a __dict__; instead allocate fixed offsets in the C struct and create one slot descriptor per name (a data descriptor — lesson 2’s machinery — that reads/writes the offset). The honest numbers: a plain empty object is ~56 bytes plus its __dict__ at ~64–100+ bytes once populated (Python ≥3.3 key-sharing dicts soften this for uniform classes, but the dict still dominates small objects); a slotted instance stores just the references — for the five-field Candle, roughly 104 bytes total versus ~250+ with a dict, which is how 8 GB became 3 GB in the Hook. Attribute access gets ~10–25% faster too: a fixed-offset read beats a dict probe.
The costs are structural, not cosmetic. No __dict__ means no dynamic attributes (candle.debug_flag = 1 raises AttributeError), no vars(obj), and some dict-dependent tooling (naive serializers, functools.cached_property, monkeypatching in tests) breaks. Inheritance is where slots quietly fail: a subclass without its own __slots__ reintroduces __dict__ and the savings evaporate; multiple inheritance from two classes that both define non-empty __slots__ raises TypeError: multiple bases have instance lay-out conflict. And slots are per-class: each subclass declares only its new fields. Dataclasses integrate directly since Python 3.10: @dataclass(slots=True) regenerates the class with __slots__ built from the fields — the ergonomic default for high-volume value objects.
When NamedTuple or attrs instead
typing.NamedTuple gives you an actual tuple: immutable, hashable, iterable, unpackable, ~equal memory to slots — right for small value records that flow through code expecting tuples, wrong when you need mutation or methods that evolve state (and its equality-with-plain-tuples can surprise: Point(1, 2) == (1, 2) is True). attrs predates dataclasses, and earns its dependency when you want validators/converters on fields without writing descriptors by hand. Rule of thumb: dataclass by default, slots=True at volume, frozen for dict keys, NamedTuple at API boundaries that speak tuples, attrs when field-level validation pulls its weight.
You add __slots__ to a hot-path class and memory barely drops. The class hierarchy is `class Candle(Base)` where Base is a plain class. Why no savings?
- 01Explain the dataclass eq/frozen/hash matrix: what does the default generate, why are instances unhashable, and what do frozen=True and unsafe_hash change?
- 02Give the honest cost-benefit of __slots__: the numbers, the failure modes, and the inheritance rules.
A class statement is executable: the body runs in a namespace, type() builds the class, and everything assigned in the body — including that innocent cache = {} — is shared class state, which is the bug @dataclass institutionalized a fix for by rejecting mutable defaults in favor of default_factory. The decorator reads annotations and synthesizes __init__, __repr__, and a field-tuple __eq__, and it obeys lesson 1’s contract mechanically: default eq nulls __hash__ (unhashable instances, TypeError as dict keys), frozen=True restores a field hash on immutable instances, unsafe_hash hashes mutable state and earns its name, order=True compares in field order whether or not that order is meaningful. __slots__ is the memory lever: replacing the per-instance __dict__ with fixed offsets behind per-class slot descriptors cuts a small object roughly 2–3x — about a hundred bytes per instance, gigabytes at 40 M rows — and speeds attribute access ~10–25%, in exchange for no dynamic attributes, no vars(), friction with dict-assuming tooling, and inheritance rules that quietly refund the savings if any base is unslotted and hard-fail when two slotted bases meet. @dataclass(slots=True) packages the whole tradeoff in one flag; NamedTuple serves tuple-shaped boundaries; attrs adds field validators when that is worth a dependency. Now when you see a pod OOM-killed well above raw payload size, or a frozen dataclass raising TypeError as a dict key, you know which knob to reach for — and which implicit contract you are about to sign.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.