Dataclasses
A dataclass generates __init__, __repr__ and __eq__ from field declarations. It removes the boilerplate from every class whose main job is holding data.
- Basics
- Data Types
- Operators
- Strings
- Control Flow
- Lists
- Tuples
- Sets
- Dictionaries
- Comprehensions
- Functions
- Advanced Functions
- Recursion
- Exception Handling
- File Handling
- Modules
- Standard Library
- OOP
- Advanced OOP
- Iterators and Generators
- Decorators
- Context Managers
- Descriptors and Dataclasses
- Python Internals
- Concurrency
- Regular Expressions
- Serialization
- Command Line Python
- Testing and Debugging
- Type Hints
- Performance
- Python Security
- DSA with Python
The boilerplate it removes
class Product:
def __init__(self, name, price, quantity=0):
self.name = name
self.price = price
self.quantity = quantity
def __repr__(self):
return (f"Product(name={self.name!r}, price={self.price!r}, "
f"quantity={self.quantity!r})")
def __eq__(self, other):
if not isinstance(other, Product):
return NotImplemented
return ((self.name, self.price, self.quantity)
== (other.name, other.price, other.quantity))from dataclasses import dataclass
@dataclass
class Product:
name: str
price: float
quantity: int = 0
p = Product("Notebook", 250)
print(p) # Product(name='Notebook', price=250, quantity=0)
print(p == Product("Notebook", 250)) # True
print(p.name, p.price)Three lines replaced eighteen. The annotations are what declare the fields; without them nothing is generated.
What gets generated
| Parameter | Default | Generates |
|---|---|---|
init | True | __init__ |
repr | True | __repr__ |
eq | True | __eq__ |
order | False | __lt__, __le__, __gt__, __ge__ |
frozen | False | Immutable instances |
slots | False | __slots__, saving memory |
kw_only | False | All fields keyword only |
from dataclasses import dataclass
@dataclass(order=True, frozen=True, slots=True)
class Version:
major: int
minor: int
patch: int = 0
versions = [Version(1, 2), Version(1, 10), Version(0, 9, 3)]
print(sorted(versions)) # ordering was generated
print(Version(1, 2) < Version(1, 10))
print(len({Version(1, 0), Version(1, 0)})) # frozen, so hashable
# Version(1, 2).major = 5 # FrozenInstanceErrororder=True compares fields as a tuple, in declaration order. That is why major is declared first.
Fields with defaults
from dataclasses import dataclass, field
@dataclass
class Cart:
customer: str
items: list = field(default_factory=list) # a NEW list per instance
tags: set = field(default_factory=set)
metadata: dict = field(default_factory=dict)
a = Cart("Meera")
b = Cart("Arun")
a.items.append("pen")
print(a.items, b.items) # ['pen'] [] - independent# This is rejected outright, which is a good thing
# @dataclass
# class Broken:
# items: list = []
# ValueError: mutable default <class 'list'> for field items is not allowedDataclasses refuse a mutable default rather than letting you create the classic shared-state bug. default_factory calls the factory once per instance.field options
from dataclasses import dataclass, field
@dataclass
class User:
username: str
email: str
password: str = field(repr=False) # hidden from repr
login_count: int = field(default=0, compare=False) # ignored by ==
session: str = field(default="", init=False) # not a constructor argument
tags: list = field(default_factory=list)
notes: str = field(default="", metadata={"help": "free text"})
u = User("meera", "m@example.com", "secret")
print(u) # the password does not appear
print(u == User("meera", "m@example.com", "secret", login_count=99)) # True__post_init__
from dataclasses import dataclass, field
@dataclass
class Rectangle:
width: float
height: float
area: float = field(init=False)
def __post_init__(self):
if self.width <= 0 or self.height <= 0:
raise ValueError("dimensions must be positive")
self.area = self.width * self.height
r = Rectangle(3, 4)
print(r)
try:
Rectangle(-1, 4)
except ValueError as error:
print(error)__post_init__ runs immediately after the generated __init__. It is where validation and derived fields belong.
Methods and properties still work
from dataclasses import dataclass, field
from datetime import date
@dataclass
class Invoice:
customer: str
lines: list = field(default_factory=list)
issued_on: date = field(default_factory=date.today)
TAX_RATE = 0.18 # no annotation, so NOT a field
def add(self, description, price, quantity=1):
self.lines.append((description, price, quantity))
return self
@property
def subtotal(self):
return sum(price * qty for _, price, qty in self.lines)
@property
def total(self):
return round(self.subtotal * (1 + self.TAX_RATE), 2)
def __str__(self):
rows = "\n".join(
f" {d:<20}{q:>3} x {p:>8,.2f}" for d, p, q in self.lines
)
return f"Invoice for {self.customer}\n{rows}\n TOTAL {self.total:>16,.2f}"
invoice = Invoice("Meera").add("Notebook", 45.50, 3).add("Pen", 8.75, 12)
print(invoice)A class attribute without an annotation is not a field. That is how TAX_RATE stays a shared constant and out of __init__.
Helper functions
from dataclasses import dataclass, asdict, astuple, replace, fields, is_dataclass
@dataclass
class Point:
x: int
y: int
label: str = ""
p = Point(3, 7, "origin")
print(asdict(p)) # {'x': 3, 'y': 7, 'label': 'origin'}
print(astuple(p)) # (3, 7, 'origin')
print(replace(p, x=10)) # a NEW Point with x changed
print(is_dataclass(p)) # True
for f in fields(p):
print(f"{f.name:<8}{f.type!s:<10}default={f.default!r}")asdict recurses into nested dataclasses, lists and dictionaries, which makes it the natural bridge to JSON.
import json
from dataclasses import dataclass, asdict, field
@dataclass
class Address:
city: str
pin: str
@dataclass
class Person:
name: str
age: int
address: Address
tags: list = field(default_factory=list)
p = Person("Meera", 27, Address("Pune", "411001"), ["staff"])
encoded = json.dumps(asdict(p), indent=2)
print(encoded)
data = json.loads(encoded)
restored = Person(
name=data["name"],
age=data["age"],
address=Address(**data["address"]),
tags=data["tags"],
)
print(restored == p) # TrueInheritance
from dataclasses import dataclass, field
@dataclass
class Employee:
name: str
salary: float
@dataclass
class Manager(Employee):
reports: list = field(default_factory=list)
bonus: float = 0.0
def total_pay(self):
return self.salary + self.bonus
m = Manager("Meera", 90_000, ["arun"], 10_000)
print(m)
print(m.total_pay())
print(isinstance(m, Employee)) # True# A field without a default cannot follow one that has a default
# @dataclass
# class Broken(Employee):
# department: str # TypeError, because Employee has no defaults here
# # but the rule bites as soon as a parent field has one
from dataclasses import dataclass
@dataclass(kw_only=True) # Python 3.10+ removes the ordering problem
class Base:
a: int = 1
@dataclass(kw_only=True)
class Child(Base):
b: int # fine, because everything is keyword only
print(Child(b=2))Choosing between the options
| Use | When |
|---|---|
dict | The shape is unknown or comes from outside |
namedtuple | A small immutable record, tuple behaviour wanted |
@dataclass | A record with a known shape, possibly with methods |
@dataclass(frozen=True) | Immutable, hashable, usable as a key |
| A plain class | Behaviour matters more than the data |
from dataclasses import dataclass
from collections import namedtuple
PointTuple = namedtuple("PointTuple", "x y")
@dataclass(frozen=True)
class PointData:
x: int
y: int
def distance_from_origin(self):
return (self.x ** 2 + self.y ** 2) ** 0.5
t = PointTuple(3, 4)
d = PointData(3, 4)
print(t.x, d.x)
print(t == PointTuple(3, 4), d == PointData(3, 4))
print(d.distance_from_origin()) # methods are easy to add
print(t[0]) # a namedtuple is also a tuple
# print(d[0]) # a dataclass is notA worked example
from dataclasses import dataclass, field, asdict
from datetime import date
from enum import Enum
class Status(Enum):
DRAFT = "draft"
PUBLISHED = "published"
ARCHIVED = "archived"
@dataclass(order=True)
class Note:
# sort_index is first so ordering uses it, and it is excluded from repr
sort_index: int = field(init=False, repr=False)
title: str
body: str = ""
status: Status = Status.DRAFT
tags: list = field(default_factory=list)
created_on: date = field(default_factory=date.today)
views: int = field(default=0, compare=False)
def __post_init__(self):
if not self.title.strip():
raise ValueError("a note must have a title")
self.title = self.title.strip()
self.sort_index = -self.views
@property
def is_visible(self):
return self.status is Status.PUBLISHED
@property
def summary(self):
return self.body[:40] + ("..." if len(self.body) > 40 else "")
def publish(self):
self.status = Status.PUBLISHED
return self
notes = [
Note("Python generators", "Generators produce values lazily.", views=120),
Note("Decorators", "A decorator wraps a function.", views=340),
Note(" Context managers ", "with guarantees cleanup."),
]
notes[0].publish()
notes[1].publish()
for note in sorted(notes):
flag = "*" if note.is_visible else " "
print(f"{flag} {note.title:<24}{note.views:>5} {note.summary}")
print()
print(asdict(notes[0])["title"])Common mistakes
- Forgetting the type annotation, so the attribute is not a field at all.
- Using a mutable default instead of
default_factory. - Putting a field without a default after one with a default.
- Expecting a non-frozen dataclass to be hashable; it is not.
- Doing validation in
__init__, which the decorator generates - use__post_init__. - Using
order=Truewithout checking that the field order gives the comparison you want. - Assuming
frozen=Trueprotects a list field; the list can still be mutated.
Best practices
- Annotate every field; use
field(default_factory=...)for mutable defaults. - Use
frozen=Truewhenever the record should not change after creation. - Use
slots=Truefor classes created in large numbers. - Validate and derive in
__post_init__. - Use
field(repr=False)for secrets andcompare=Falsefor volatile fields. - Use
asdictat the boundary when converting to JSON.
Practice
- Convert a class with a hand written
__init__,__repr__and__eq__into a dataclass. - Write a frozen, ordered dataclass and use instances as dictionary keys.
- Add validation and a computed field using
__post_init__. - Show the error for a mutable default and fix it with
default_factory. - Round trip a nested dataclass through JSON and prove equality is preserved.
Conclusion
@dataclass writes the boilerplate for any class whose job is to hold data, and it refuses the mutable default bug outright. Annotate the fields, use default_factory for containers, validate in __post_init__, and freeze it when it should not change.