Enums, slots, and Dataclasses
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.pyOutput:
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.pyOutput:
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.pyOutput:
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.pyOutput:
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.pyOutput:
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.pyOutput:
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
Enumfor a handful of named states. Compare members, not.value, inside the program.@dataclassfor records. Field annotations are the constructor.@dataclass(frozen=True)when the value is a key or accidental assignment would be a defect.- No
slots=Trueuntil 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@dataclassunless you have read whatfield()and__post_init__are for — and you still might not need them.
Try this
- Add
Status.VOID = "void"and alabelbranch that printsvoidedfor that member. - Make
Orderfrozen. Try to adda.cents = 0and print the exception type. - Combine
@dataclass(frozen=True, slots=True)onShift. Confirm you still cannot sethours, and that there is no__dict__. - Replace the string in
status_typo.pywithStatus. PassStatus.PAIDand a wrong construction (Status("payed")) and show theValueError.