By the end of this chapter, you should understand:
- What a dataclass is.
- Why dataclasses exist.
- How
@dataclasschanges a class. - Why dataclasses depend on type annotations.
- Which methods Python can generate automatically.
- How dataclass fields are discovered.
- How defaults and
default_factorywork. - Why mutable defaults are dangerous.
- How
field()gives you per-field control. - How
__post_init__supports validation and derived state. - How frozen dataclasses model value objects.
- How equality, ordering, and hashing work.
- How dataclasses interact with inheritance.
- How
ClassVarandInitVarchange field behavior. - How
asdict,astuple, andreplaceshould be used. - How dataclasses compare with normal classes, tuples, dictionaries, and named tuples.
- When dataclasses are a good fit.
- When dataclasses are the wrong tool.
Chapter 50 studied ABCs and mixins.
Those tools help us express behavior contracts and reusable behavior.
Dataclasses solve a different problem.
Many classes are not mainly about behavior.
They are mainly about structured data.
For example:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Point(x={self.x!r}, y={self.y!r})"
def __eq__(self, other):
if type(other) is not Point:
return NotImplemented
return self.x == other.x and self.y == other.yThis class is not complicated.
But it is repetitive.
The important idea is:
a point has x and y
The code spends most of its space saying:
copy constructor arguments into attributes
print the attributes
compare the attributes
Dataclasses let you write the idea directly:
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: intThat is still a real class.
It still has instances.
It still has attributes.
It can still have methods.
But Python generates common object methods for you.
Dataclasses are not magic replacements for object-oriented design.
They are a tool for classes whose shape is mostly data.
Consider a small program that tracks books:
class Book:
def __init__(self, title, author, pages):
self.title = title
self.author = author
self.pages = pagesThis works.
But the moment you print a Book, the output is not useful:
book = Book("Fluent Python", "Luciano Ramalho", 1014)
print(book)You may see something like:
<__main__.Book object at 0x104ac7190>
So you add __repr__:
class Book:
def __init__(self, title, author, pages):
self.title = title
self.author = author
self.pages = pages
def __repr__(self):
return (
f"Book(title={self.title!r}, "
f"author={self.author!r}, "
f"pages={self.pages!r})"
)Then you compare two books:
first = Book("Python", "A", 300)
second = Book("Python", "A", 300)
print(first == second)Without __eq__, this is usually False, because the two variables refer to different objects.
So you add equality:
class Book:
def __init__(self, title, author, pages):
self.title = title
self.author = author
self.pages = pages
def __repr__(self):
return (
f"Book(title={self.title!r}, "
f"author={self.author!r}, "
f"pages={self.pages!r})"
)
def __eq__(self, other):
if type(other) is not Book:
return NotImplemented
return (
self.title == other.title
and self.author == other.author
and self.pages == other.pages
)This is correct enough.
But now three fields created a lot of mechanical code.
If you add a fourth field, you must update multiple methods.
If you forget one, the class becomes inconsistent.
Dataclasses reduce that repetition.
from dataclasses import dataclass
@dataclass
class Book:
title: str
author: str
pages: intThis generates useful methods based on the fields.
You still wrote the class.
Python filled in common object behavior.
This point matters.
A dataclass is not a dictionary.
It is not a tuple.
It is not a special record object from another language.
It is an ordinary Python class processed by the @dataclass decorator.
from dataclasses import dataclass
@dataclass
class User:
name: str
email: strYou create instances normally:
user = User("Maya", "maya@example.com")You access attributes normally:
print(user.name)
print(user.email)You can mutate attributes unless the dataclass is frozen:
user.email = "maya@company.com"You can define methods:
@dataclass
class User:
name: str
email: str
def display_name(self):
return self.name.title()You can use inheritance.
You can use composition.
You can implement ABCs.
You can mix in behavior.
The dataclass decorator does not remove the class model.
It adds generated methods to the class model.
Here is a tiny dataclass:
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: intThis says:
Point has two fields: x and y
The annotations are important:
x: int
y: intDataclasses discover fields from annotations.
If you write:
@dataclass
class Point:
x = 0
y = 0then x and y are not dataclass fields.
They are ordinary class attributes.
The dataclass decorator uses __annotations__ to decide which attributes are fields.
This is why dataclasses and type hints often appear together.
But the annotations are not runtime validators by default.
This class:
@dataclass
class Point:
x: int
y: intdoes not automatically reject strings:
point = Point("left", "top")Python will create the object unless you add validation yourself or use a separate validation library.
The annotation tells tools and readers what should be there.
The dataclass decorator uses the annotation to identify the field.
By default, @dataclass generates:
__init____repr____eq__
It can also generate ordering methods and hashing behavior depending on options.
Start with:
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: intPython behaves as if you had written an initializer like:
def __init__(self, x: int, y: int):
self.x = x
self.y = yIt behaves as if you had a representation like:
Point(x=10, y=20)It behaves as if equality compares field values:
Point(1, 2) == Point(1, 2)returns:
TrueBut:
Point(1, 2) == Point(2, 1)returns:
FalseThe generated methods use the fields in the order they appear in the class body.
Field order matters for:
- constructor parameter order
- representation order
- comparison order
- ordering behavior when enabled
- pattern matching support
So this:
@dataclass
class User:
id: int
name: stris not identical in generated constructor shape to this:
@dataclass
class User:
name: str
id: intSame fields.
Different order.
The generated __init__ accepts one argument for each field that has init=True.
Example:
@dataclass
class Rectangle:
width: float
height: floatYou can create:
box = Rectangle(10.0, 5.0)or:
box = Rectangle(width=10.0, height=5.0)The generated initializer assigns:
self.width = width
self.height = heightIt does not call property setters unless the fields are designed through descriptors or properties in a more advanced pattern.
For ordinary dataclasses, fields become ordinary instance attributes.
You can add methods that use those attributes:
@dataclass
class Rectangle:
width: float
height: float
def area(self):
return self.width * self.heightThe method is your code.
The initializer is generated.
That is the common dataclass shape:
generated data plumbing + handwritten domain behavior
Dataclass fields can have default values:
@dataclass
class User:
name: str
active: bool = TrueNow active is optional:
user = User("Maya")
print(user.active)Output:
TrueThis is similar to default function arguments.
The generated initializer acts like:
def __init__(self, name: str, active: bool = True):
self.name = name
self.active = activeFields without defaults must come before fields with defaults.
This is valid:
@dataclass
class Task:
title: str
done: bool = FalseThis is not valid:
@dataclass
class Task:
done: bool = False
title: strThe reason is the generated initializer would need to look like:
def __init__(self, done=False, title):
...Python function syntax does not allow a required positional parameter after a default positional parameter.
The rule also matters across inheritance.
If a base dataclass has default fields, a subclass cannot casually add required positional fields after them.
We will return to inheritance later in this chapter.
Mutable defaults are one of the most important dataclass lessons.
Suppose we want a shopping cart:
from dataclasses import dataclass
@dataclass
class Cart:
items: list[str] = []This is wrong.
A list is mutable.
If a default list were shared across instances, changing one cart could affect another cart.
Python's dataclasses protect against common mutable defaults by raising an error for many built-in mutable cases.
The correct version uses default_factory:
from dataclasses import dataclass, field
@dataclass
class Cart:
items: list[str] = field(default_factory=list)Now each Cart gets its own new list:
first = Cart()
second = Cart()
first.items.append("book")
print(first.items)
print(second.items)Output:
['book']
[]Think of it this way:
default value -> one object written in the class body
default_factory -> function called for each new instance
For immutable defaults, use plain defaults:
@dataclass
class Config:
retries: int = 3
debug: bool = FalseFor mutable defaults, use factories:
@dataclass
class Inbox:
messages: list[str] = field(default_factory=list)
labels: set[str] = field(default_factory=set)
metadata: dict[str, str] = field(default_factory=dict)This is not just dataclass trivia.
It is the same object model from Volume I.
Lists, dictionaries, and sets are objects.
If you reuse one mutable object as a default, you reuse the same state.
default_factory asks Python to create fresh state per instance.
The field() function lets you configure one field.
Example:
from dataclasses import dataclass, field
@dataclass
class User:
username: str
password_hash: str = field(repr=False)Now printing the user will not expose the password hash:
user = User("maya", "hash-value")
print(user)Output:
User(username='maya')The field still exists:
print(user.password_hash)But it is excluded from repr.
Common field() options include:
defaultdefault_factoryinitreprcomparehashmetadatakw_only
Recent Python versions also support a field-level documentation value through doc.
You will not use every option every day.
But you should understand the most common ones.
Sometimes a field should exist on the instance but should not be accepted directly by the constructor.
Example:
from dataclasses import dataclass, field
@dataclass
class Invoice:
subtotal: float
tax_rate: float
total: float = field(init=False)
def __post_init__(self):
self.total = self.subtotal * (1 + self.tax_rate)Now callers pass:
invoice = Invoice(100.0, 0.18)They do not pass total.
The object computes it.
This can be useful, but it should be used carefully.
If the computed value must always match other fields, ask whether it should be stored at all.
Sometimes this is better:
@dataclass
class Invoice:
subtotal: float
tax_rate: float
@property
def total(self):
return self.subtotal * (1 + self.tax_rate)The stored version captures a value at initialization time.
The property version computes from current state every time.
Choose based on the meaning you want.
The generated __init__ assigns fields.
After it assigns fields, it calls __post_init__ if the class defines one.
This gives you a hook for:
- validation
- normalization
- derived attributes
- setup that depends on multiple fields
Example:
from dataclasses import dataclass
@dataclass
class Percentage:
value: float
def __post_init__(self):
if not 0 <= self.value <= 100:
raise ValueError("percentage must be between 0 and 100")Now invalid objects are rejected:
Percentage(50)
Percentage(120) # ValueErrorDataclasses do not validate types automatically.
But __post_init__ can validate values.
Example:
@dataclass
class User:
email: str
def __post_init__(self):
if "@" not in self.email:
raise ValueError("email must contain @")
self.email = self.email.strip().lower()This normalizes email addresses after initialization.
Be careful with normalization.
Changing user input during construction can be helpful.
It can also hide bugs if done too aggressively.
Use __post_init__ when the object has an invariant.
An invariant is a rule that should always be true for a valid object.
For Percentage, the invariant is:
value is between 0 and 100
For DateRange, it might be:
start is not after end
Example:
from dataclasses import dataclass
from datetime import date
@dataclass
class DateRange:
start: date
end: date
def __post_init__(self):
if self.start > self.end:
raise ValueError("start date cannot be after end date")The purpose of __post_init__ is not to stuff random setup into an object.
Its best use is protecting the object's meaning.
Dataclasses use annotations to find fields.
This does not mean Python enforces the annotation at runtime.
Example:
@dataclass
class Product:
name: str
price: floatThis says price should be a float.
But this code still runs by default:
product = Product("Keyboard", "expensive")The generated initializer does not check:
isinstance(price, float)Why not?
Because Python's type hints are primarily hints.
They help:
- readers
- editors
- static type checkers
- documentation tools
- dataclass field discovery
If you need runtime validation, you must write it:
@dataclass
class Product:
name: str
price: float
def __post_init__(self):
if not isinstance(self.name, str):
raise TypeError("name must be a string")
if not isinstance(self.price, int | float):
raise TypeError("price must be numeric")Even this is incomplete for a full production validation system.
For example, bool is a subclass of int, decimals may matter, and coercion rules may differ by application.
Dataclasses are not validation frameworks.
They are class-generation tools.
Static type checking appears later in the book.
For now, remember:
annotation identifies intent
dataclass uses it to find fields
runtime type enforcement is separate
The generated __repr__ is one of the immediate joys of dataclasses.
Example:
@dataclass
class Coordinate:
lat: float
lon: floatPrinting gives:
Coordinate(lat=12.9716, lon=77.5946)That is much better than a memory address.
Good representations help when:
- debugging
- writing tests
- reading logs
- inspecting objects in a REPL
- comparing expected and actual values
But not every field belongs in repr.
Exclude sensitive or noisy fields:
@dataclass
class ApiToken:
name: str
token: str = field(repr=False)Exclude fields that are too large:
@dataclass
class Dataset:
name: str
rows: list[dict[str, object]] = field(repr=False)Do not blindly expose secrets because dataclasses make representation convenient.
A generated representation is useful only if it is safe and readable.
By default, dataclasses compare by field values.
@dataclass
class Point:
x: int
y: intThen:
Point(1, 2) == Point(1, 2)is:
TrueThis is value equality.
It connects directly to Volume I's identity and equality chapter.
Identity asks:
are these the same object?
Equality asks:
are these objects considered equal?
Dataclasses choose field-based equality by default.
This is excellent for value-like objects:
@dataclass
class Money:
amount: int
currency: strTwo Money(100, "INR") objects should probably compare equal.
But field equality is not always correct.
Consider:
@dataclass
class UserAccount:
id: int
email: str
last_login_ip: strShould two accounts be equal if all fields match?
Maybe.
Should they stop being equal when last_login_ip changes?
Probably not.
For entity objects, equality often depends on identity fields, not every field.
You can exclude fields from comparison:
@dataclass
class UserAccount:
id: int
email: str
last_login_ip: str = field(compare=False)Now equality ignores last_login_ip.
You can also disable generated equality entirely:
@dataclass(eq=False)
class UserAccount:
id: int
email: strThen Python falls back to normal object equality unless you define your own __eq__.
The main design question is:
is this object a value or an entity?
A value object is defined by its contents.
An entity is defined by identity, lifecycle, or external meaning.
Dataclasses are often wonderful for value objects.
They must be used more carefully for entities.
Dataclasses do not generate ordering methods by default.
This means:
@dataclass
class Score:
points: int
player: strThis works:
Score(10, "A") == Score(10, "A")But this does not work by default:
Score(10, "A") < Score(20, "B")To generate ordering methods, use order=True:
@dataclass(order=True)
class Score:
points: int
player: strNow ordering compares fields in definition order.
That means:
Score(10, "Zara") < Score(20, "Asha")is true because 10 < 20.
If points match, it compares player next.
This tuple-like ordering can be useful.
But it can also be misleading.
For a scoreboard, you may want highest points first.
For a task list, you may want priority first, then due date.
Field order becomes sorting logic.
Sometimes that is too implicit.
A common pattern is to create a hidden sort key:
from dataclasses import dataclass, field
@dataclass(order=True)
class Task:
sort_index: tuple[int, str] = field(init=False, repr=False)
priority: int
title: str
def __post_init__(self):
self.sort_index = (self.priority, self.title)But do not reach for this too quickly.
Often an explicit key function is clearer:
tasks.sort(key=lambda task: (task.priority, task.title))Use order=True when the type has one natural ordering.
Use sort keys when the ordering depends on context.
Hashing is where dataclass behavior becomes subtle.
Objects used as dictionary keys or set elements need a hash.
But hashable objects should not change in ways that affect equality.
Example:
@dataclass
class Point:
x: int
y: intThis dataclass is mutable by default.
It has generated equality.
So Python does not make it hashable by default.
This usually fails:
points = {Point(1, 2)}Why?
Because if a mutable object could be placed in a set and then changed, the set's internal hash table could become inconsistent.
Frozen dataclasses are different:
@dataclass(frozen=True)
class Point:
x: int
y: intNow Point(1, 2) can usually be hashed if all fields are hashable:
points = {Point(1, 2)}The broad rule is:
mutable + value equality -> usually unhashable
frozen + value equality -> usually hashable
There is an unsafe_hash=True option.
The name is honest.
Use it only when you truly understand the consequences.
If an object can change after being used as a dictionary key, you can create bugs that are difficult to see.
Hashing is not just a method-generation detail.
It is a promise about object stability.
A frozen dataclass prevents normal attribute assignment after creation.
from dataclasses import dataclass
@dataclass(frozen=True)
class Money:
amount: int
currency: strNow:
price = Money(100, "INR")
price.amount = 200raises an error.
Frozen dataclasses are useful for value objects:
@dataclass(frozen=True)
class Coordinate:
lat: float
lon: floatThey express:
this object should not change after construction
But frozen does not mean deep immutability.
This is still risky:
@dataclass(frozen=True)
class FrozenCart:
items: list[str]You cannot assign a new list:
cart.items = []But you can mutate the existing list:
cart.items.append("book")The dataclass is frozen at the attribute-assignment level.
It does not recursively freeze every object referenced by its fields.
If you want a deeply immutable shape, use immutable field types:
@dataclass(frozen=True)
class FrozenCart:
items: tuple[str, ...]This is much safer.
The object model lesson is familiar:
an attribute stores a reference
freezing the attribute does not freeze every object behind the reference
Sometimes a frozen dataclass needs derived state.
Normal assignment in __post_init__ does not work:
@dataclass(frozen=True)
class Slug:
text: str
value: str = field(init=False)
def __post_init__(self):
self.value = self.text.lower().replace(" ", "-") # errorBecause the instance is frozen.
The escape hatch is object.__setattr__:
@dataclass(frozen=True)
class Slug:
text: str
value: str = field(init=False)
def __post_init__(self):
slug = self.text.strip().lower().replace(" ", "-")
object.__setattr__(self, "value", slug)Use this sparingly.
It is appropriate during initialization.
It should not become a back door for random mutation.
The meaning of frozen should remain clear:
after construction, this object behaves as immutable
Dataclasses can support keyword-only fields.
This is useful when positional arguments would become unclear.
Example:
@dataclass(kw_only=True)
class Email:
to: str
subject: str
body: str
urgent: bool = FalseNow callers must use keywords:
message = Email(
to="maya@example.com",
subject="Welcome",
body="Hello",
urgent=True,
)This avoids unclear calls like:
Email("maya@example.com", "Welcome", "Hello", True)For small mathematical objects, positional arguments are fine:
Point(10, 20)For business objects, keyword-only construction is often clearer:
UserProfile(
username="maya",
display_name="Maya Rao",
newsletter_opt_in=True,
)You can also mark individual fields keyword-only with field(kw_only=True).
Use keyword-only fields when:
- there are many fields
- several fields have the same type
- booleans appear in the constructor
- call sites should read like declarations
Boolean positional arguments are especially hard to read:
Account("maya", True, False, True)This tells the reader almost nothing.
Keyword arguments tell the story:
Account(
username="maya",
active=True,
verified=False,
staff=True,
)Sometimes a class attribute should not become a dataclass field.
Use ClassVar for that.
from dataclasses import dataclass
from typing import ClassVar
@dataclass
class User:
username: str
table_name: ClassVar[str] = "users"Here username is an instance field.
table_name is a class variable.
It is not included in the generated initializer.
It is not included in equality.
It is not returned by dataclass field inspection.
This matters because dataclasses use annotations.
Without ClassVar, this:
@dataclass
class User:
username: str
table_name: str = "users"would treat table_name as an instance field.
That may not be what you mean.
Use ClassVar when the value belongs to the class as a whole.
Examples:
@dataclass
class Currency:
code: str
minor_units: int
registry_name: ClassVar[str] = "iso-4217"The registry name describes the class concept.
It is not separate data for each instance.
InitVar creates an initialization-only parameter.
It is accepted by __init__.
It is passed to __post_init__.
It is not stored as a normal dataclass field.
Example:
from dataclasses import dataclass, InitVar
@dataclass
class User:
username: str
raw_password: InitVar[str]
password_hash: str = ""
def __post_init__(self, raw_password):
self.password_hash = f"hashed:{raw_password}"You construct:
user = User("maya", "secret")The raw password is used during initialization.
It is not stored as user.raw_password.
This is useful for values needed only to build the object.
Another example:
@dataclass
class Report:
title: str
data: list[int]
normalize: InitVar[bool] = False
def __post_init__(self, normalize):
if normalize:
total = sum(self.data)
if total:
self.data = [value / total for value in self.data]The normalize parameter changes construction behavior.
It is not part of the final object's data.
Use InitVar when a constructor input is not an instance attribute.
Do not overuse it.
If many initialization-only inputs are needed, your construction process may deserve a separate factory function or builder.
A common beginner mistake is thinking dataclasses should not have methods.
They absolutely can.
Example:
@dataclass(frozen=True)
class Money:
amount: int
currency: str
def add(self, other):
if self.currency != other.currency:
raise ValueError("cannot add different currencies")
return Money(self.amount + other.amount, self.currency)This is a strong dataclass.
It has:
- simple data
- clear invariants
- meaningful behavior
- value semantics
A dataclass is not merely a passive bag of attributes.
It can be a real domain object.
Another example:
@dataclass
class Rectangle:
width: float
height: float
def area(self):
return self.width * self.height
def scale(self, factor):
self.width *= factor
self.height *= factorThe class stores state and defines behavior related to that state.
That is ordinary object-oriented design.
The dataclass decorator only removed repetitive method definitions.
A dictionary is flexible:
user = {
"name": "Maya",
"email": "maya@example.com",
}You can add any key:
user["last_login"] = "today"That flexibility is useful.
But it also means typos can become runtime bugs:
print(user["emial"])A dataclass has named attributes:
@dataclass
class User:
name: str
email: strThen:
user = User("Maya", "maya@example.com")Attribute names are visible in the class definition.
Editors and type checkers can help.
Refactoring is easier.
The object can have behavior.
But dictionaries are still better for:
- truly dynamic keys
- JSON-like data with unknown shape
- quick accumulation
- flexible mappings
Dataclasses are better for:
- known fields
- domain concepts
- structured inputs and outputs
- readable object representations
- value objects
Do not replace every dictionary with a dataclass.
Use dataclasses when the structure deserves a name.
A tuple is positional:
point = (10, 20)This is compact.
But the meaning of each element lives outside the tuple.
You must remember:
index 0 is x
index 1 is y
A dataclass names the parts:
@dataclass(frozen=True)
class Point:
x: int
y: intNow:
point.x
point.ycarry meaning.
Tuples are good for:
- small ad hoc groupings
- returning multiple values locally
- fixed positional protocols
- immutable sequences
Dataclasses are good when:
- fields need names
- behavior may grow
- equality should be value-based
- the concept deserves a type
This function is fine:
def min_max(values):
return min(values), max(values)But this result probably deserves a dataclass:
@dataclass(frozen=True)
class SearchResult:
title: str
url: str
snippet: str
score: floatThe more meaning the grouped data has, the more useful a named class becomes.
Named tuples also provide named fields.
Example:
from typing import NamedTuple
class Point(NamedTuple):
x: int
y: intNamed tuples are immutable and tuple-like.
They support indexing:
point = Point(10, 20)
print(point[0])Dataclasses are class-like by default.
They do not behave like tuples unless you add such behavior yourself.
Use a named tuple when tuple compatibility matters.
Use a dataclass when object modeling matters.
Example:
@dataclass(frozen=True)
class Point:
x: int
y: intThis is usually better when you want a domain object.
Named tuples are excellent for lightweight immutable records.
Dataclasses are more flexible as your model grows.
The asdict() function converts a dataclass instance into a dictionary.
from dataclasses import dataclass, asdict
@dataclass
class User:
name: str
email: str
user = User("Maya", "maya@example.com")
print(asdict(user))Output:
{'name': 'Maya', 'email': 'maya@example.com'}Nested dataclasses are converted recursively:
@dataclass
class Address:
city: str
country: str
@dataclass
class Customer:
name: str
address: AddressThen:
customer = Customer("Maya", Address("Bengaluru", "India"))
print(asdict(customer))Output:
{'name': 'Maya', 'address': {'city': 'Bengaluru', 'country': 'India'}}This is useful for serialization boundaries.
But there is an important detail:
asdict() recursively copies dataclass structures and uses deep-copy behavior for many contained objects.
That can be more expensive than expected.
For shallow conversion, use field inspection:
from dataclasses import fields
def shallow_asdict(obj):
return {field.name: getattr(obj, field.name) for field in fields(obj)}Use asdict() when you want recursive conversion.
Use a custom function when you need exact control.
The astuple() function converts a dataclass instance into a tuple.
from dataclasses import dataclass, astuple
@dataclass
class Point:
x: int
y: int
point = Point(10, 20)
print(astuple(point))Output:
(10, 20)Like asdict(), it recursively handles nested dataclasses.
This can be useful when:
- interoperating with tuple-based code
- writing tests
- converting to simple structures
But do not use astuple() as a habit.
If the names matter, a dictionary or the dataclass itself may be clearer.
Tuple conversion removes field names.
That can make data less readable.
The replace() function creates a new dataclass instance with some fields changed.
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class Settings:
theme: str
notifications: bool
settings = Settings("light", True)
dark_settings = replace(settings, theme="dark")The original remains:
Settings(theme='light', notifications=True)The new one is:
Settings(theme='dark', notifications=True)This is especially useful for frozen dataclasses.
Instead of mutating:
settings.theme = "dark"you create a modified copy:
settings = replace(settings, theme="dark")This style is common in functional programming and immutable object design.
It can make state transitions easier to reason about.
But remember that replace() creates the new object through the dataclass initializer.
That means __post_init__ runs again.
This is usually good.
It preserves validation.
But it can surprise you if __post_init__ has side effects.
Another reason to keep __post_init__ focused on object invariants.
The fields() function returns information about dataclass fields.
from dataclasses import dataclass, fields
@dataclass
class User:
name: str
email: str
for field in fields(User):
print(field.name, field.type)This can power:
- serializers
- form generators
- schema tools
- test helpers
- debugging utilities
Each field object includes information such as:
- name
- type
- default
- default factory
- whether it appears in
init - whether it appears in
repr - whether it participates in comparison
- metadata
You should use fields() instead of poking at private dataclass internals.
If you want field names, ask the dataclass system.
Do not rely on generated __slots__, private attributes, or implementation details.
Dataclass fields can carry metadata.
Dataclasses themselves do not use this metadata.
It is provided for your tools and frameworks.
Example:
from dataclasses import dataclass, field, fields
@dataclass
class Product:
name: str = field(metadata={"label": "Product name"})
price: float = field(metadata={"label": "Unit price"})You can inspect it:
for item in fields(Product):
print(item.name, item.metadata.get("label"))Metadata can support:
- form labels
- serialization names
- validation hints
- database mapping hints
- documentation generation
But be careful.
Too much metadata can turn a simple dataclass into a mini framework.
If metadata begins to carry core business logic, consider a clearer design.
Dataclasses can generate __slots__ by using slots=True:
@dataclass(slots=True)
class Point:
x: int
y: intThis can reduce per-instance memory overhead and prevent arbitrary new attributes.
Example:
point = Point(1, 2)
point.z = 3 # errorSlots are useful when:
- you create many instances
- memory matters
- you want a fixed attribute layout
But slots also have tradeoffs.
They affect weak references, inheritance, attribute storage, and some dynamic patterns.
Because __slots__ deserves its own careful treatment, Chapter 56 studies it in depth.
For now, know that dataclasses can participate in slot-based design.
Do not turn on slots=True everywhere just because it looks efficient.
Measure first.
Design second.
Optimize when needed.
In Chapter 37, we studied weak references.
Slot-based classes need special support if their instances should be weak-referenceable.
Dataclasses provide weakref_slot=True for this case:
@dataclass(slots=True, weakref_slot=True)
class Node:
name: strThis adds the weak-reference slot needed by the object layout.
You only use this when:
- the dataclass uses
slots=True - instances need to be targets of weak references
Most dataclasses do not need this option.
It exists for advanced memory-sensitive designs.
The important connection is that dataclasses are not isolated from Python internals.
They sit directly on top of the same object model, attribute model, and memory model we have been building.
Python's structural pattern matching can work with dataclasses.
Example:
@dataclass
class Point:
x: int
y: intYou can match by position:
def describe(point):
match point:
case Point(0, 0):
return "origin"
case Point(x, 0):
return f"x-axis at {x}"
case Point(0, y):
return f"y-axis at {y}"
case Point(x, y):
return f"point at {x}, {y}"Dataclasses generate __match_args__ by default.
That tells pattern matching which fields correspond to positional patterns.
Keyword patterns are often clearer:
match point:
case Point(x=0, y=0):
print("origin")For large dataclasses, prefer keyword patterns.
They are more readable and less fragile if field order changes.
You can disable generated match args:
@dataclass(match_args=False)
class Event:
type: str
payload: dict[str, object]Most code can leave the default alone.
But it is useful to know that dataclasses participate in the broader data model.
Dataclasses can inherit from dataclasses.
Example:
@dataclass
class Person:
name: str
@dataclass
class Employee(Person):
employee_id: intNow:
employee = Employee("Maya", 101)The generated initializer includes fields from the base dataclass and the subclass.
Inheritance is useful when the relationship is genuinely an is-a relationship.
But inheritance with dataclasses can become tricky because:
- field order matters
- default fields affect subclass constructor rules
- generated methods must combine fields correctly
- multiple inheritance can become difficult to reason about
Example of a potential problem:
@dataclass
class Base:
active: bool = True
@dataclass
class User(Base):
username: strThis creates a required field after a default field through inheritance.
The generated initializer shape would be invalid.
A common fix is keyword-only fields:
@dataclass(kw_only=True)
class Base:
active: bool = True
@dataclass
class User(Base):
username: strAnother fix is redesigning the hierarchy.
Do not force inheritance just to reuse fields.
Composition is often clearer:
@dataclass
class AuditInfo:
created_by: str
active: bool = True
@dataclass
class User:
username: str
audit: AuditInfoDataclass inheritance is powerful.
But like all inheritance, it should represent a real conceptual relationship.
Generated dataclass initializers do not automatically call every non-dataclass base class initializer in the way beginners might expect.
Suppose:
class Resource:
def __init__(self):
self.closed = FalseAnd:
@dataclass
class FileResource(Resource):
path: strThe generated initializer for FileResource focuses on dataclass fields.
If the base initializer must run, call it from __post_init__:
@dataclass
class FileResource(Resource):
path: str
def __post_init__(self):
super().__init__()This is one reason to be cautious with dataclasses that inherit from complex non-dataclass bases.
Dataclasses are simplest when the class hierarchy is simple.
When base classes perform important setup, read the constructor flow carefully.
The MRO chapter is relevant here.
Generated methods are still methods.
Inheritance still follows Python's method resolution rules.
A dataclass can implement an abstract base class.
Example:
from abc import ABC, abstractmethod
from dataclasses import dataclass
class Shape(ABC):
@abstractmethod
def area(self):
raise NotImplementedError
@dataclass
class Rectangle(Shape):
width: float
height: float
def area(self):
return self.width * self.heightThis is a natural combination.
The ABC defines required behavior.
The dataclass stores the data needed for that behavior.
Another example:
class Command(ABC):
@abstractmethod
def execute(self):
raise NotImplementedError
@dataclass
class SendEmail(Command):
to: str
subject: str
body: str
def execute(self):
print(f"sending email to {self.to}")This is often cleaner than writing repetitive constructors for every command class.
The design question remains:
what is generated data plumbing, and what behavior must I write?
Dataclasses handle the plumbing.
ABCs define behavioral obligations.
Your methods implement the domain meaning.
Dataclasses can use mixins too.
Example:
class JsonMixin:
def to_json_dict(self):
return self.__dict__.copy()
@dataclass
class User(JsonMixin):
name: str
email: strThis works for simple non-slotted dataclasses.
But be careful with mixins that assume __dict__.
If you later use slots=True, instances may not have a normal __dict__.
A better mixin can use dataclass inspection:
from dataclasses import fields
class DataclassDictMixin:
def to_dict(self):
return {
field.name: getattr(self, field.name)
for field in fields(self)
}Then:
@dataclass
class User(DataclassDictMixin):
name: str
email: strThis mixin understands dataclasses.
But it should probably check that self is a dataclass instance if used broadly.
Mixin design still follows the rules from Chapter 50:
- keep it small
- avoid hidden assumptions
- document what it expects
- cooperate with the object model
Dataclasses work well with properties, but you must separate stored fields from computed attributes.
Example:
@dataclass
class Rectangle:
width: float
height: float
@property
def area(self):
return self.width * self.heightHere width and height are fields.
area is computed.
It is not a dataclass field.
This is usually the right design when the value can be derived cheaply.
Avoid storing redundant data unless needed.
Less ideal:
@dataclass
class Rectangle:
width: float
height: float
area: float = field(init=False)
def __post_init__(self):
self.area = self.width * self.heightIf width changes later, area becomes stale.
You can prevent that with a frozen dataclass:
@dataclass(frozen=True)
class Rectangle:
width: float
height: float
@property
def area(self):
return self.width * self.heightNow the derived value always reflects the immutable fields.
Chapter 55 studies properties, static methods, and class methods in more detail.
For dataclasses, the basic rule is:
store what must be stored
compute what can be computed clearly
Sometimes you do not want a generated initializer.
You can disable it:
@dataclass(init=False)
class User:
name: str
email: str
def __init__(self, name):
self.name = name
self.email = f"{name.lower()}@example.com"This is valid.
But if you write most methods yourself, ask whether @dataclass is still helping.
Another approach is to keep the generated initializer and add class methods:
@dataclass
class User:
name: str
email: str
@classmethod
def from_username(cls, username):
return cls(
name=username,
email=f"{username.lower()}@example.com",
)This often keeps the primary object shape simple.
Use factory methods when:
- there are multiple construction paths
- input needs parsing
- construction has a name
- you want to preserve the generated initializer
Use custom __init__ when:
- the generated initializer fundamentally does not match the object
- initialization must be completely controlled
- field assignment is unusual
Dataclasses are most valuable when you let them generate common behavior.
If you disable everything, you may be using the wrong tool.
Suppose we want tasks sorted by priority, but equality should care only about an ID.
This is a design smell if pushed too far, but it is useful to understand.
from dataclasses import dataclass, field
@dataclass
class Task:
id: int
title: str
priority: int = field(compare=False)Now priority is ignored by equality.
Task(1, "Write", 5) == Task(1, "Write", 1)is:
Truebecause priority is excluded.
You can exclude noisy or operational fields:
@dataclass
class Job:
id: str
payload: dict[str, object]
attempts: int = field(default=0, compare=False)
last_error: str | None = field(default=None, compare=False)This means:
same id and payload -> same job identity for comparison
attempt history does not affect equality
Be explicit.
If comparison behavior is not obvious, write tests.
Generated equality is convenient, but equality is part of your domain model.
Treat it with care.
Dataclasses are excellent for value objects.
A value object is defined by its values rather than a separate identity.
Examples:
Money(100, "INR")Point(10, 20)DateRange(start, end)EmailAddress("maya@example.com")Version(1, 4, 2)
A strong value object is often:
- small
- validated
- immutable
- comparable by value
- easy to print
Dataclasses support this shape:
@dataclass(frozen=True)
class Version:
major: int
minor: int
patch: int = 0
def __post_init__(self):
if self.major < 0 or self.minor < 0 or self.patch < 0:
raise ValueError("version numbers cannot be negative")
def __str__(self):
return f"{self.major}.{self.minor}.{self.patch}"This is a good dataclass.
It has clear data.
It has a clear invariant.
It has useful behavior.
It should not change after creation.
Entities are different.
An entity has identity and lifecycle.
Example:
@dataclass
class User:
id: int
email: str
display_name: strThis may be fine.
But ask:
when are two User objects equal?
If equality should be based only on id, the generated equality may be wrong.
Two objects representing the same database user might have different display names because one is stale.
Should they compare unequal?
Maybe not.
For entities, consider:
@dataclass(eq=False)
class User:
id: int
email: str
display_name: str
def __eq__(self, other):
if type(other) is not User:
return NotImplemented
return self.id == other.id
def __hash__(self):
return hash(self.id)Or avoid custom equality and compare IDs explicitly.
The dataclass can still be useful for constructor and representation generation.
But the default value semantics may not fit every entity.
The design principle is:
do not let generated methods quietly define your domain semantics by accident
Dataclasses are excellent for configuration.
Example:
@dataclass(frozen=True)
class ServerConfig:
host: str
port: int
debug: bool = False
workers: int = 4
def __post_init__(self):
if not 1 <= self.port <= 65535:
raise ValueError("port must be between 1 and 65535")
if self.workers < 1:
raise ValueError("workers must be positive")Configuration objects benefit from:
- named fields
- defaults
- validation
- readable representation
- immutability
- easy replacement
Example:
production = ServerConfig("0.0.0.0", 8000)
debug = replace(production, debug=True)This is much clearer than passing dictionaries through the program.
It gives configuration a shape.
It also makes invalid configuration fail early.
Dataclasses can model internal request and response objects.
Example:
@dataclass(frozen=True)
class CreateUserRequest:
email: str
display_name: str
@dataclass(frozen=True)
class CreateUserResponse:
user_id: int
email: strThis can make service boundaries clear:
def create_user(request: CreateUserRequest) -> CreateUserResponse:
...But remember:
Dataclasses do not parse JSON automatically.
They do not validate untrusted data automatically.
At external boundaries, you may need explicit parsing or a validation library.
Inside your application, dataclasses can be excellent structured messages.
At the boundary, be careful.
Never assume that external dictionaries are valid just because you can unpack them:
request = CreateUserRequest(**payload)That may fail.
Or worse, it may succeed with values of the wrong runtime type.
External input deserves explicit validation.
Dataclasses often sit near serialization code.
Example:
@dataclass
class Event:
type: str
payload: dict[str, object]You might convert it:
data = asdict(event)But serialization is not always simple.
What about:
- dates
- decimals
- enums
- bytes
- nested custom classes
- private fields
- renamed fields
- optional fields
- versioned schemas
asdict() is a structural conversion.
It is not a complete serialization policy.
For serious APIs, design serialization deliberately.
Example:
@dataclass(frozen=True)
class Event:
type: str
created_at: datetime
def to_json_dict(self):
return {
"type": self.type,
"created_at": self.created_at.isoformat(),
}This method states the external representation.
That is often better than exposing internal object shape automatically.
Dataclasses are in the standard library.
They are lightweight.
They do not perform rich runtime validation.
Other tools can offer more:
- runtime type validation
- parsing
- coercion
- schema generation
- error reporting
- JSON integration
Those tools are useful, but they are not the same thing as dataclasses.
Dataclasses are best understood as:
standard-library support for generating common class methods from annotated fields
If your problem is:
I want less boilerplate in simple classes
dataclasses are a good fit.
If your problem is:
I need robust validation of untrusted nested JSON
you may need more than dataclasses.
This distinction prevents disappointment.
Use the right tool at the right boundary.
Most regular dataclass instances have a __dict__.
Example:
@dataclass
class User:
name: str
email: strThen:
user = User("Maya", "maya@example.com")
print(user.__dict__)Output:
{'name': 'Maya', 'email': 'maya@example.com'}This is the same attribute storage model we studied earlier.
But do not build important code by assuming every dataclass has __dict__.
With slots=True, the instance may not have one.
With inheritance or descriptors, attribute storage may differ.
Use dataclass tools when you mean dataclass fields:
from dataclasses import fields
def extract(obj):
return {
field.name: getattr(obj, field.name)
for field in fields(obj)
}This says what you mean.
You want dataclass fields, not arbitrary instance storage.
If you define a method that dataclasses would otherwise generate, your method can take precedence depending on the option.
Example:
@dataclass
class User:
name: str
def __repr__(self):
return f"<User {self.name}>"The dataclass decorator will not replace this __repr__.
You wrote it.
Python keeps it.
This gives you control.
But be careful when combining generated and custom methods.
If you customize __eq__, consider hashing.
If you customize __init__, consider __post_init__.
If you customize __repr__, consider whether sensitive fields are handled.
Generated methods are meant to be coherent as a group.
When you replace one, make sure the rest still make sense.
The full @dataclass decorator has several options.
You do not need to memorize them all.
But you should recognize what each controls.
Common options:
@dataclass(
init=True,
repr=True,
eq=True,
order=False,
unsafe_hash=False,
frozen=False,
match_args=True,
kw_only=False,
slots=False,
weakref_slot=False,
)
class Example:
...The practical meanings:
init generate __init__
repr generate __repr__
eq generate __eq__
order generate ordering methods
unsafe_hash force hash generation
frozen block normal assignment after creation
match_args support positional pattern matching
kw_only make fields keyword-only
slots generate slots
weakref_slot support weak references with slots
Most dataclasses use the default:
@dataclass
class Example:
...Some use:
@dataclass(frozen=True)or:
@dataclass(order=True)or:
@dataclass(kw_only=True)Use options to express design.
Do not turn options on because they exist.
Let us design a small Money type.
First attempt:
@dataclass
class Money:
amount: int
currency: strThis works.
But money values should probably not mutate casually.
Use frozen=True:
@dataclass(frozen=True)
class Money:
amount: int
currency: strNow add validation:
@dataclass(frozen=True)
class Money:
amount: int
currency: str
def __post_init__(self):
if not isinstance(self.amount, int):
raise TypeError("amount must be stored in minor units as an integer")
if len(self.currency) != 3:
raise ValueError("currency must be a 3-letter code")Now add behavior:
@dataclass(frozen=True)
class Money:
amount: int
currency: str
def __post_init__(self):
if not isinstance(self.amount, int):
raise TypeError("amount must be stored in minor units as an integer")
if len(self.currency) != 3:
raise ValueError("currency must be a 3-letter code")
def add(self, other):
if self.currency != other.currency:
raise ValueError("cannot add different currencies")
return Money(self.amount + other.amount, self.currency)This is no longer just data storage.
It is a small domain type.
Dataclasses support this style well.
Suppose a search system returns results:
@dataclass(frozen=True)
class SearchResult:
title: str
url: str
snippet: str
score: floatThis is a clean data container.
It has a clear shape.
It prints well.
It compares by value.
It can be converted to a dictionary.
But suppose you want to sort by score descending.
Do not necessarily use order=True.
This is clearer:
results.sort(key=lambda result: result.score, reverse=True)Sorting by score is a use-case-specific operation.
The type itself does not need a universal ordering.
This is a common design judgment.
Generated ordering is powerful, but explicit sorting often communicates intent better.
Dataclasses are useful for command objects.
@dataclass(frozen=True)
class CreateProject:
name: str
owner_id: intThe command describes an action.
It can be passed through handlers:
def handle_create_project(command: CreateProject):
...This is clearer than passing several loose parameters through multiple layers:
handle_create_project(name, owner_id)The dataclass gives the bundle a name.
It improves readability.
It also makes future changes easier:
@dataclass(frozen=True)
class CreateProject:
name: str
owner_id: int
template_id: int | None = NoneNow the command can evolve without changing every function signature in the same way.
Do not overdo this for tiny local functions.
But for application boundaries, named request and command objects can make code easier to understand.
This is wrong:
@dataclass
class Age:
value: int
age = Age("old")The annotation does not stop this.
If your object must be valid, write validation:
@dataclass(frozen=True)
class Age:
value: int
def __post_init__(self):
if not isinstance(self.value, int):
raise TypeError("age must be an integer")
if self.value < 0:
raise ValueError("age cannot be negative")Or validate before constructing the object.
Or use a runtime validation tool.
The key is not to assume.
Dataclass annotations are not runtime guards by default.
A dataclass with many fields can become hard to use:
@dataclass
class UserProfile:
first_name: str
last_name: str
email: str
phone: str
address_line_1: str
address_line_2: str
city: str
state: str
country: str
postal_code: str
newsletter: bool
sms_alerts: bool
dark_mode: boolThis may be acceptable in some contexts.
But often it signals missing structure.
Break related concepts apart:
@dataclass
class Address:
line_1: str
line_2: str
city: str
state: str
country: str
postal_code: str
@dataclass
class Preferences:
newsletter: bool
sms_alerts: bool
dark_mode: bool
@dataclass
class UserProfile:
first_name: str
last_name: str
email: str
phone: str
address: Address
preferences: PreferencesThis uses composition.
The object becomes easier to read.
The constructor becomes more meaningful.
The structure reflects the domain.
Dataclasses make large data shapes easy to write.
They do not make large data shapes automatically good.
Suppose you have:
@dataclass
class Order:
subtotal: float
discount: float
tax_rate: floatAnd then many functions:
def order_total(order):
return (order.subtotal - order.discount) * (1 + order.tax_rate)
def order_is_discounted(order):
return order.discount > 0This may be fine for simple procedural code.
But if these operations define what an order means, methods may be clearer:
@dataclass
class Order:
subtotal: float
discount: float
tax_rate: float
def total(self):
return (self.subtotal - self.discount) * (1 + self.tax_rate)
def is_discounted(self):
return self.discount > 0Dataclasses should not make you afraid of methods.
If behavior belongs to the data, keep it near the data.
Dataclasses are comfortable.
That makes them easy to overuse.
Do not use a dataclass when:
- the object has complex lifecycle behavior
- identity matters more than field values
- fields are highly dynamic
- construction must perform significant work
- validation and parsing dominate the problem
- a plain tuple or dictionary is clearer
- a normal class better expresses the design
Example where a normal class may be better:
class DatabaseConnection:
def __init__(self, dsn):
self.dsn = dsn
self._connection = None
def connect(self):
...
def close(self):
...This is not mainly structured data.
It is a resource with lifecycle.
A dataclass would not add much.
It might even make the class seem more value-like than it really is.
The right question is:
does this class primarily model structured state?
If yes, consider a dataclass.
If no, use a normal class.
This dataclass:
@dataclass
class Payment:
amount: int
currency: str
captured: bool
refunded: boolallows:
Payment(1000, "INR", True, False)That call is not very readable.
For business objects, prefer keyword-only:
@dataclass(kw_only=True)
class Payment:
amount: int
currency: str
captured: bool
refunded: boolThen:
Payment(
amount=1000,
currency="INR",
captured=True,
refunded=False,
)The call is longer.
But it is safer and clearer.
The constructor is part of your API.
Design it deliberately.
This is dangerous:
@dataclass
class Rectangle:
width: float
height: float
area: float = field(init=False)
def __post_init__(self):
self.area = self.width * self.heightWhy?
Because:
rectangle = Rectangle(10, 20)
rectangle.width = 100
print(rectangle.area)The area is now stale.
Better options:
Use a property:
@dataclass
class Rectangle:
width: float
height: float
@property
def area(self):
return self.width * self.heightOr freeze the object:
@dataclass(frozen=True)
class Rectangle:
width: float
height: float
area: float = field(init=False)
def __post_init__(self):
object.__setattr__(self, "area", self.width * self.height)The property is usually simpler.
Store derived values only when:
- computation is expensive
- the object is immutable
- the cached value has clear invalidation rules
Use default when the default value is already the value you want:
@dataclass
class RetryPolicy:
attempts: int = field(default=3)This is equivalent to:
@dataclass
class RetryPolicy:
attempts: int = 3Use default_factory when you need a new value per instance:
@dataclass
class Buffer:
items: list[str] = field(default_factory=list)Do not write:
field(default_factory=list())That calls list() immediately.
You want to pass the callable:
field(default_factory=list)The factory must take no arguments.
If you need arguments, wrap it:
def default_tags():
return ["new"]
@dataclass
class Article:
tags: list[str] = field(default_factory=default_tags)Or use a lambda carefully:
@dataclass
class Article:
tags: list[str] = field(default_factory=lambda: ["new"])Named functions are often clearer than lambdas when defaults have meaning.
This is risky:
@dataclass
class Credentials:
username: str
password: strPrinting:
Credentials("maya", "secret")shows the password.
Use:
@dataclass
class Credentials:
username: str
password: str = field(repr=False)Also consider whether the password should be stored at all.
Maybe only a password hash belongs in memory.
Maybe even the hash should be excluded from repr.
Generated methods are convenient.
Security still requires judgment.
When you design a dataclass, ask:
What concept does this class name?
If you cannot name the concept clearly, you may just need a local tuple or dictionary.
Ask:
Are these fields stable and known?
Dataclasses like known structure.
Ask:
Should instances be mutable?
If not, use frozen=True.
Ask:
Should equality compare all fields?
If not, configure fields or define equality yourself.
Ask:
Should construction be positional or keyword-only?
For business objects, keyword-only is often better.
Ask:
Are any defaults mutable?
Use default_factory.
Ask:
Are there invariants?
Use __post_init__.
Ask:
Are there secrets or huge fields?
Exclude them from repr.
Ask:
Am I using generated methods deliberately?
Generated methods define object behavior.
They are not just convenience.
Let us build a small todo model.
Start with a value object for priority:
from dataclasses import dataclass, field, replace
from datetime import date
@dataclass(frozen=True, order=True)
class Priority:
level: int
def __post_init__(self):
if not 1 <= self.level <= 5:
raise ValueError("priority must be between 1 and 5")This is small, immutable, and ordered.
Now define a task:
@dataclass(kw_only=True)
class Task:
title: str
priority: Priority
due: date | None = None
done: bool = False
tags: list[str] = field(default_factory=list)We use keyword-only construction because the object has several fields:
task = Task(
title="Write chapter",
priority=Priority(2),
tags=["book", "python"],
)Add behavior:
@dataclass(kw_only=True)
class Task:
title: str
priority: Priority
due: date | None = None
done: bool = False
tags: list[str] = field(default_factory=list)
def mark_done(self):
self.done = True
def is_overdue(self, today: date):
return self.due is not None and self.due < today and not self.doneThis is a mutable entity-like task.
It changes over time.
Now suppose we want immutable task updates instead:
@dataclass(frozen=True, kw_only=True)
class Task:
title: str
priority: Priority
due: date | None = None
done: bool = False
tags: tuple[str, ...] = ()
def mark_done(self):
return replace(self, done=True)
def is_overdue(self, today: date):
return self.due is not None and self.due < today and not self.doneNow:
task = task.mark_done()returns a new task.
Neither version is universally better.
The mutable version is simple.
The frozen version is easier to reason about in some workflows.
Dataclasses support both.
Your design chooses the meaning.
Suppose external configuration arrives as a dictionary:
raw = {
"host": "127.0.0.1",
"port": "8000",
"debug": "false",
}Do not blindly do:
config = ServerConfig(**raw)because port and debug are strings.
Instead, parse at the boundary:
@dataclass(frozen=True)
class ServerConfig:
host: str
port: int
debug: bool = False
def __post_init__(self):
if not 1 <= self.port <= 65535:
raise ValueError("invalid port")
def parse_bool(value: str) -> bool:
normalized = value.strip().lower()
if normalized in {"true", "1", "yes"}:
return True
if normalized in {"false", "0", "no"}:
return False
raise ValueError(f"invalid boolean: {value!r}")
def parse_config(raw: dict[str, str]) -> ServerConfig:
return ServerConfig(
host=raw["host"],
port=int(raw["port"]),
debug=parse_bool(raw.get("debug", "false")),
)The dataclass represents valid configuration.
The parsing function handles messy external input.
This separation is important:
external data is messy
internal objects should be meaningful
Dataclasses help with the internal object.
They do not remove the need for boundary handling.
Dataclasses are useful for events:
from dataclasses import dataclass, field
from datetime import datetime, timezone
from uuid import UUID, uuid4
@dataclass(frozen=True, kw_only=True)
class UserRegistered:
user_id: UUID
email: str
occurred_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
event_id: UUID = field(default_factory=uuid4)This class has:
- required fields
- generated defaults
- immutability
- readable representation
- clear event identity
But think about equality.
Should two events with the same user_id and email compare equal if they have different event_id values?
Probably not.
The generated equality includes all comparable fields by default.
That may be exactly right here.
But if occurred_at should not affect equality, configure it:
occurred_at: datetime = field(
default_factory=lambda: datetime.now(timezone.utc),
compare=False,
)This is a domain decision.
The dataclass gives you the mechanism.
You provide the meaning.
Dataclasses are ordinary Python classes with generated methods.
They are not automatically faster than handwritten classes.
They are usually chosen for clarity and maintainability.
Performance questions include:
- How many instances will exist?
- How often are they created?
- Are they mutable or frozen?
- Do they use
slots=True? - Do conversions like
asdict()create deep copies? - Are large fields included in
repror comparison?
For most programs, the performance difference is not the deciding factor.
Use dataclasses for clearer code.
Measure before optimizing.
If millions of instances are created, investigate slots=True, memory layout, and alternatives.
If large nested structures are converted frequently, avoid casual asdict().
If comparisons are expensive, exclude fields that do not belong in comparison.
Performance follows design.
A clean object shape is easier to optimize later.
Static type checkers understand dataclasses.
They can often infer:
- generated initializer parameters
- field types
- missing arguments
- wrong argument types
- frozen assignment errors
- default value mismatches
Example:
@dataclass(frozen=True)
class Point:
x: int
y: intA static checker can warn about:
Point("left", "top")even though Python may run it.
This is one reason dataclasses and type hints pair well.
Runtime Python stays dynamic.
Static tools add an optional checking layer.
Later in the book, static type checking gets its own treatment.
For now, see dataclasses as a bridge:
runtime classes + annotated structure + generated methods + tool support
Dataclasses are a perfect transition into the next part of Volume II.
Why?
Because @dataclass is mostly about generating data model methods.
It can generate:
__init____repr____eq____lt____le____gt____ge____hash____match_args__
These methods are not random names.
They are part of Python's data model.
They determine how objects:
- construct
- display
- compare
- sort
- hash
- match
Dataclasses are convenient because they generate these methods.
But to use dataclasses deeply, you must understand what those methods mean.
That is exactly where the book goes next.
Start with:
class Book:
def __init__(self, title, author, pages):
self.title = title
self.author = author
self.pages = pages
def __repr__(self):
return f"Book(title={self.title!r}, author={self.author!r}, pages={self.pages!r})"Convert it to a dataclass.
Then add:
- a default value for
pages - validation that pages cannot be negative
- a method called
is_long
One possible solution:
@dataclass
class Book:
title: str
author: str
pages: int = 0
def __post_init__(self):
if self.pages < 0:
raise ValueError("pages cannot be negative")
def is_long(self):
return self.pages >= 500Notice that the dataclass handles plumbing.
You still write the meaningful behavior.
This class is wrong:
@dataclass
class Playlist:
name: str
songs: list[str] = []Fix it.
Solution:
@dataclass
class Playlist:
name: str
songs: list[str] = field(default_factory=list)Then test:
first = Playlist("Morning")
second = Playlist("Evening")
first.songs.append("Song A")
assert second.songs == []The test proves each instance gets a separate list.
Create an immutable EmailAddress dataclass.
Requirements:
- it stores one string field called
value - it strips whitespace
- it lowercases the address
- it rejects values without
@ - it is frozen
One possible solution:
@dataclass(frozen=True)
class EmailAddress:
value: str
def __post_init__(self):
normalized = self.value.strip().lower()
if "@" not in normalized:
raise ValueError("invalid email address")
object.__setattr__(self, "value", normalized)This example shows a frozen object with normalization.
The assignment must use object.__setattr__ because the dataclass is frozen.
Suppose you have:
@dataclass
class Session:
session_id: str
user_id: int
last_seen_at: datetimeAsk:
Should two sessions compare equal if last_seen_at differs?
If the answer is no, keep the default.
If the answer is yes, exclude last_seen_at:
@dataclass
class Session:
session_id: str
user_id: int
last_seen_at: datetime = field(compare=False)Then write tests that express the chosen behavior.
Equality is not only technical.
It is semantic.
Design a dataclass for a payment:
@dataclass(kw_only=True)
class Payment:
amount: int
currency: str
captured: bool = False
refunded: bool = FalseCreate it with keyword arguments:
payment = Payment(
amount=1000,
currency="INR",
captured=True,
)Try to create it positionally:
Payment(1000, "INR", True, False)It should fail.
That failure is useful.
It protects call sites from unclear positional booleans.
For each concept, decide whether a dataclass is a good fit:
Point(x, y)
DatabaseConnection
UserRegistrationCommand
Logger
Money(amount, currency)
Cache
SearchResult
ThreadPool
Likely choices:
Point -> dataclass
DatabaseConnection -> normal class
UserRegistrationCommand -> dataclass
Logger -> normal class or existing logging object
Money -> frozen dataclass
Cache -> normal class
SearchResult -> dataclass
ThreadPool -> normal class
The pattern:
structured data -> dataclass
lifecycle/resource/behavior-heavy object -> normal class
Dataclasses reduce boilerplate for classes that primarily model structured data.
They are ordinary Python classes processed by the @dataclass decorator.
Fields are discovered from type annotations.
By default, dataclasses generate __init__, __repr__, and __eq__.
They can also generate ordering, hashing, pattern matching support, and slot-based layouts.
Defaults work like constructor defaults.
Mutable defaults require default_factory.
The field() function gives per-field control over initialization, representation, comparison, hashing, metadata, and keyword-only behavior.
__post_init__ is the right place for validation, normalization, and derived setup after generated initialization.
Frozen dataclasses are useful for value objects, but freezing is not deep immutability.
ClassVar marks class-level data that should not become a field.
InitVar accepts initialization-only values that are passed to __post_init__ but not stored as fields.
asdict(), astuple(), replace(), and fields() provide useful helper behavior, but each has design implications.
Dataclasses work with inheritance, ABCs, mixins, properties, and static type checking.
They are powerful because they sit directly on Python's class and data model.
Use dataclasses when a named concept has known structured fields.
Use normal classes when lifecycle, identity, dynamic behavior, or complex construction dominates.
The most important design lesson is:
dataclasses generate mechanics, but you still own meaning
Chapter 51 completes Volume II Part I.
We have now built a complete object-oriented foundation:
- classes
- instances
- attributes
- methods
- encapsulation
- composition
- inheritance
- overriding
- MRO
super()- polymorphism
- duck typing
- ABCs
- mixins
- dataclasses
Next we begin Part II: the Python data model.
Chapter 52 studies dunder methods.
Dataclasses have already shown why these methods matter.
@dataclass can generate methods such as __init__, __repr__, __eq__, and __hash__.
But Python's data model is much larger than dataclasses.
It explains how objects participate in:
- construction
- display
- comparison
- arithmetic
- containers
- iteration
- function calls
- context management
- attribute access
The transition is natural:
dataclasses generate selected data model methods
Chapter 52 teaches the data model itself
Once you understand dunder methods directly, Python objects become much less mysterious.