Enums, slots, and Dataclasses

Updated

September 8, 2026

Enums, slots, and Dataclasses

Three tools for data that has outgrown a dict: an Enum for a closed set of names, a dataclass for a record, and slots as a layout opt-in. The boring default is a dataclass without slots. Add an Enum when a string status starts getting typos. Add slots when a profiler says the extra __dict__ on millions of rows is the bill.

Mental model

enum.Enum members are singletons. Status.OPEN is Status.OPEN is true. Construction from a value (Status("open")) either returns that member or raises ValueError. That is the point: "payed" cannot sneak through.

@dataclass writes __init__, __repr__, and __eq__ from annotated fields. The annotations here are field declarations, not a typing chapter. You already met dataclasses as records; this chapter is the knobs: frozen=True, slots=True, and when to leave both off.

Slots replace the per-instance __dict__ with a fixed set of attributes. You cannot assign t.note = "window" later. That saves memory and catches typos. It also gets in the way of mixins, optional fields, and debugging. Measure first.

Worked examples

Case 1: a closed kitchen status

Save as ticket_status.py. Compare members with is. Read the stored string with .value only at the edge (print, JSON).

# ticket_status.py
from enum import Enum


class Status(Enum):
    OPEN = "open"
    FIRED = "fired"
    PAID = "paid"


def label(status):
    return f"kitchen: {status.value}"


def main():
    s = Status.OPEN
    print(s)
    print(s is Status.OPEN)
    print(label(s))
    try:
        Status("lost")
    except ValueError as e:
        print(e)


if __name__ == "__main__":
    main()

Run:

uv run python ticket_status.py

Output:

Status.OPEN
True
kitchen: open
'lost' is not a valid Status

Do not loop over string literals in business code. Loop over Status if you need every member.

Case 2: a dataclass record

Save as order_row.py. Field types tell @dataclass what __init__ accepts. Equality is by value.

# order_row.py
from dataclasses import dataclass


@dataclass
class Order:
    id: int
    table: int
    cents: int


def main():
    a = Order(1, 12, 850)
    b = Order(1, 12, 850)
    print(a)
    print(a == b)


if __name__ == "__main__":
    main()

Run:

uv run python order_row.py

Output:

Order(id=1, table=12, cents=850)
True

This is the default you want for desk rows: mutable, repr for free, no slots.

Case 3: freeze when mutation is a bug

Save as frozen_shift.py. A published shift card should not grow an extra hour because a later line assigned s.hours = 5.

# frozen_shift.py
from dataclasses import dataclass


@dataclass(frozen=True)
class Shift:
    name: str
    hours: int


def main():
    s = Shift("open", 4)
    print(s)
    try:
        s.hours = 5
    except AttributeError as e:
        print(type(e).__name__)


if __name__ == "__main__":
    main()

Run:

uv run python frozen_shift.py

Output:

Shift(name='open', hours=4)
FrozenInstanceError

FrozenInstanceError is an AttributeError. Catch AttributeError if you must; better: do not assign. Frozen values can be dict keys if the fields themselves are hashable.

Case 4: slots when you opt in

Save as slotted_ticket.py. slots=True drops __dict__. Extra attributes fail.

# slotted_ticket.py
from dataclasses import dataclass


@dataclass(slots=True)
class Ticket:
    id: int
    table: int


def main():
    t = Ticket(7, 12)
    print(t)
    print(hasattr(t, "__dict__"))
    try:
        t.note = "window"
    except AttributeError as e:
        print(type(e).__name__)


if __name__ == "__main__":
    main()

Run:

uv run python slotted_ticket.py

Output:

Ticket(id=7, table=12)
False
AttributeError

The same dataclass without slots still reprs, and it lets you hang a note on the instance — convenient, and a source of “where did this attribute come from?”

# extra_field.py
from dataclasses import dataclass


@dataclass
class Ticket:
    id: int
    table: int


def main():
    t = Ticket(7, 12)
    t.note = "window"
    print(t)
    print(t.note)


if __name__ == "__main__":
    main()

Run:

uv run python extra_field.py

Output:

Ticket(id=7, table=12)
window

print(t) does not show note. The extra field lives only on __dict__. If you needed note, it should have been a field.

The trap

String statuses fail closed in the wrong direction: they fail open. A typo is just another string.

# status_typo.py
def is_paid(status):
    return status == "paid"


def main():
    print(is_paid("payed"))


if __name__ == "__main__":
    main()

Run:

uv run python status_typo.py

Output:

False

The kitchen never marks the ticket paid. No exception. Use Status.PAID.

The other trap is putting slots=True on every dataclass because a post said it is faster. Slots and multiple inheritance fight. Slots and “I’ll add a cache attribute later” fight. Frozen plus slots plus a cached property is how you end up calling object.__setattr__. Leave slots off until a measurement names this class.

The boring rule

  • Enum for a handful of named states. Compare members, not .value, inside the program.
  • @dataclass for records. Field annotations are the constructor.
  • @dataclass(frozen=True) when the value is a key or accidental assignment would be a defect.
  • No slots=True until you have many instances and a profile. The default dataclass is the default.
  • Do not invent extra attributes on instances. Add a field.
  • Do not mix a hand-written __init__ with @dataclass unless you have read what field() and __post_init__ are for — and you still might not need them.

Try this

  1. Add Status.VOID = "void" and a label branch that prints voided for that member.
  2. Make Order frozen. Try to add a.cents = 0 and print the exception type.
  3. Combine @dataclass(frozen=True, slots=True) on Shift. Confirm you still cannot set hours, and that there is no __dict__.
  4. Replace the string in status_typo.py with Status. Pass Status.PAID and a wrong construction (Status("payed")) and show the ValueError.