5.7 Classes and Dataclasses
Table of Contents
Why Classes Matter in Backend Python
In backend development you quickly reach a point where simple variables and functions are not enough. You need a way to group related data and behavior together. That is exactly what classes are for.
You will not learn every object oriented programming concept here. Other chapters cover OOP in more detail. In this chapter we focus on how to use classes and especially Python dataclasses in typical backend code.
Recap: What Is a Class?
A class is a blueprint for creating objects. An object is an instance of a class with its own data.
In backend code you will often model things like:
- User
- Order
- Product
- Invoice
- Task
Each of these can be a class.
A Simple Class Example
class User:
def __init__(self, id: int, email: str, is_active: bool = True):
self.id = id
self.email = email
self.is_active = is_active
def deactivate(self) -> None:
self.is_active = False
def __repr__(self) -> str:
return f"User(id={self.id}, email={self.email!r}, is_active={self.is_active})"
user = User(id=1, email="alice@example.com")
print(user) # User(id=1, email='alice@example.com', is_active=True)
user.deactivate()
print(user.is_active) # FalseKey ideas:
__init__runs when you createUser(...).selfis the instance you are working on.- Methods like
deactivatechange the object state.
For small data holder classes you end up writing repetitive __init__, __repr__, __eq__, and more. Dataclasses help with that.
What Is a Dataclass?
A dataclass is a Python decorator that automatically adds common methods to classes that mainly store data.
You import and use it like this:
from dataclasses import dataclass
@dataclass
class User:
id: int
email: str
is_active: bool = True
The @dataclass decorator automatically generates:
__init____repr____eq__- and other methods, depending on options
This makes your backend code shorter and easier to maintain.
Rule: Use @dataclass when a class mainly holds data and has simple behavior.
Use a regular class when the class has complex initialization, heavy logic, or you need full control over methods.
Creating Your First Dataclass
Basic Dataclass Example
from dataclasses import dataclass
@dataclass
class Product:
id: int
name: str
price: float
in_stock: bool = True
p = Product(id=10, name="Keyboard", price=49.99)
print(p) # Product(id=10, name='Keyboard', price=49.99, in_stock=True)
print(p.name) # Keyboard
p.in_stock = False
With @dataclass you did not need to write __init__ or __repr__.
With Type Hints (Required)
Dataclasses require type hints for each field to know what is a field:
@dataclass
class User:
id: int
email: str
is_active: boolIf you forget the type, it will not be treated as a dataclass field.
Default Values and Field Order
Dataclasses behave like functions in Python with respect to default values.
Rule for Default Values
In a dataclass, all fields without defaults must come before fields with defaults.
from dataclasses import dataclass
# Correct
@dataclass
class Order:
id: int # no default
total_amount: float # no default
status: str = "new" # default value
If you put status before total_amount, you will get an error.
@dataclass
class BrokenOrder:
status: str = "new"
total_amount: float # ❌ This will raise: non-default argument follows default argument
Rule: In a dataclass definition, non default fields must come before default fields.
Example of invalid order:
Using `field` for More Control
Sometimes you want a default value that is not a simple constant, or you want to exclude a field from comparisons or representation. For this you use dataclasses.field.
from dataclasses import dataclass, field
@dataclass
class Order:
id: int
total_amount: float
status: str = "new"
internal_notes: str = field(default="", repr=False, compare=False)Here:
repr=Falsehides the field inprint(order).compare=Falseremoves it from equality checks.
This is useful in backend code for things like internal flags, secrets, or technical metadata.
Mutable Default Values, `field(default_factory=...)`
In backend development you often have lists or dicts as fields. For example, an order may have a list of items.
You must be very careful with mutable default values.
The Wrong Way
from dataclasses import dataclass
@dataclass
class Order:
id: int
items: list = [] # ❌ Dangerous mutable default
This list is created once. All Order instances share the same list.
o1 = Order(id=1)
o2 = Order(id=2)
o1.items.append("apple")
print(o1.items) # ['apple']
print(o2.items) # ['apple'] ← same list reused, very badThe Correct Way: `default_factory`
Use field(default_factory=...) to create a new list or dict each time.
from dataclasses import dataclass, field
from typing import List
@dataclass
class Order:
id: int
items: List[str] = field(default_factory=list)
o1 = Order(id=1)
o2 = Order(id=2)
o1.items.append("apple")
print(o1.items) # ['apple']
print(o2.items) # []Now each order has its own list.
Other common factories:
from typing import Dict, Any
@dataclass
class RequestContext:
headers: Dict[str, str] = field(default_factory=dict)
attrs: Dict[str, Any] = field(default_factory=dict)
Rule: Never use a mutable object (like [], {}, set()) as a direct default value in a dataclass field.
Always use field(default_factory=...) so each instance gets its own object.
Auto-generated Methods
@dataclass can automatically create several methods. You can control which ones you want.
`__init__`
Created by default, uses type hints and defaults.
@dataclass
class User:
id: int
email: str
is_admin: bool = FalseThis is similar to writing:
class User:
def __init__(self, id: int, email: str, is_admin: bool = False):
self.id = id
self.email = email
self.is_admin = is_admin
If you need custom initialization, you can implement your own __post_init__ or override __init__ manually.
`__repr__`
Useful for debugging backend code.
user = User(1, "alice@example.com")
print(user) # User(id=1, email='alice@example.com', is_admin=False)
If you do not want a field in the representation, use repr=False as shown earlier.
`__eq__` (Equality)
By default dataclasses compare objects by field values.
@dataclass
class User:
id: int
email: str
u1 = User(1, "alice@example.com")
u2 = User(1, "alice@example.com")
print(u1 == u2) # TrueThis is useful for testing and business logic.
If you do not want comparison, set eq=False:
@dataclass(eq=False)
class User:
id: int
email: strOrder Comparison (`order=True`)
If your objects have ordering, for example by id, you can set order=True.
from dataclasses import dataclass
@dataclass(order=True)
class Task:
priority: int
description: strNow you can sort tasks:
tasks = [
Task(3, "Low priority"),
Task(1, "High priority"),
Task(2, "Medium priority"),
]
for t in sorted(tasks):
print(t.priority)
# 1, 2, 3`__post_init__`: Post Processing After Initialization
When you want @dataclass to still generate __init__ but you also need custom logic afterwards, use __post_init__.
The method __post_init__ runs automatically right after the dataclass generated __init__.
Example: Basic Validation
from dataclasses import dataclass
@dataclass
class User:
id: int
email: str
def __post_init__(self):
if "@" not in self.email:
raise ValueError("Invalid email address")Now:
User(1, "alice@example.com") # OK
User(2, "invalid-email") # Raises ValueErrorExample: Derived Fields
from dataclasses import dataclass, field
@dataclass
class Product:
name: str
price: float
price_with_tax: float = field(init=False)
def __post_init__(self):
self.price_with_tax = self.price * 1.20Here:
init=Falsemeansprice_with_taxis not part of__init__arguments.- You set it inside
__post_init__.
Dataclasses and Immutability (`frozen=True`)
Sometimes you want objects that cannot be changed after creation, similar to tuple. This can help avoid bugs in concurrent or complex backend systems.
You can use frozen=True for that.
from dataclasses import dataclass
@dataclass(frozen=True)
class Config:
database_url: str
redis_url: str
debug: bool = FalseNow any attempt to modify a field will raise an error:
cfg = Config(database_url="postgres://...", redis_url="redis://...")
cfg.debug = True # ❌ dataclasses.FrozenInstanceErrorCaveat: Nested Mutables
Even if a dataclass is frozen, mutable fields inside it can still change.
from dataclasses import dataclass, field
from typing import Dict
@dataclass(frozen=True)
class Settings:
feature_flags: Dict[str, bool] = field(default_factory=dict)
s = Settings()
s.feature_flags["new_ui"] = True # This still works, inner dict is mutable
To make it truly immutable, you must use immutable inner types, for example tuples or types.MappingProxyType.
Converting Dataclasses to Dicts and Back
In backend development you often need to:
- Convert objects to dictionaries or JSON for responses.
- Read data from JSON or databases into objects.
`asdict` and `astuple`
Use dataclasses.asdict for a deep conversion to dict.
from dataclasses import dataclass, asdict
from typing import List
@dataclass
class Item:
name: str
price: float
@dataclass
class Order:
id: int
items: List[Item]
order = Order(
id=1,
items=[Item("apple", 1.0), Item("banana", 2.0)],
)
print(asdict(order))
# {'id': 1, 'items': [{'name': 'apple', 'price': 1.0}, {'name': 'banana', 'price': 2.0}]}For tuples:
from dataclasses import astuple
print(astuple(order))
# (1, (('apple', 1.0), ('banana', 2.0)))
This is useful when you want to log structured data or send it as JSON (after passing it to json.dumps or a web framework).
Examples From Backend Use Cases
Example 1: Simple DTO (Data Transfer Object)
In backend APIs you often create small classes that represent request or response data. Dataclasses are perfect for this when you do not use heavier tools like Pydantic.
from dataclasses import dataclass
from datetime import datetime
@dataclass
class CreateUserRequest:
email: str
password: str
@dataclass
class UserResponse:
id: int
email: str
created_at: datetimeYou can use these around your service layer to keep data structured and typed.
Example 2: Domain Model Object
from dataclasses import dataclass, field
from datetime import datetime
from typing import List
@dataclass
class OrderItem:
product_id: int
quantity: int
unit_price: float
@property
def total_price(self) -> float:
return self.quantity * self.unit_price
@dataclass
class Order:
id: int
user_id: int
items: List[OrderItem] = field(default_factory=list)
created_at: datetime = field(default_factory=datetime.utcnow)
is_paid: bool = False
@property
def total_amount(self) -> float:
return sum(item.total_price for item in self.items)
def add_item(self, product_id: int, quantity: int, unit_price: float) -> None:
self.items.append(OrderItem(product_id, quantity, unit_price))
def mark_paid(self) -> None:
self.is_paid = TrueHere the dataclasses are not just dumb containers. They also provide convenient methods and properties for your business logic.
Example 3: Configuration Object
from dataclasses import dataclass
import os
@dataclass
class AppConfig:
database_url: str
redis_url: str
debug: bool
@classmethod
def from_env(cls) -> "AppConfig":
return cls(
database_url=os.environ["DATABASE_URL"],
redis_url=os.environ.get("REDIS_URL", "redis://localhost:6379/0"),
debug=os.environ.get("DEBUG", "false").lower() == "true",
)
config = AppConfig.from_env()This pattern is common in backend apps, where configuration comes from environment variables.
When to Use Dataclasses vs Regular Classes
Both are valid in backend projects. Choose based on your needs.
| Situation | Use |
|---|---|
| Mostly data, few methods | Dataclass |
Need automatic __init__, __repr__, __eq__ | Dataclass |
| You want immutability | Frozen dataclass |
| Complex inheritance hierarchies | Regular classes (often) |
| Framework requires normal classes | Regular classes |
| Heavy custom initialization logic | Regular class or dataclass with __post_init__ |
Often you start with a dataclass, and if it grows too complex, you convert it into a normal class.
Common Pitfalls and How to Avoid Them
1. Mutable Defaults
Already covered, but it is so important it is worth repeating.
Use:
field(default_factory=list)
field(default_factory=dict)Not:
items: list = []
options: dict = {}2. Wrong Field Order
Do not put fields with defaults before fields without defaults. Keep this order:
@dataclass
class Example:
required1: int
required2: str
optional1: str = "x"
optional2: int = 03. Forgetting Type Hints
Fields without type hints do not become dataclass fields.
@dataclass
class User:
id: int
email = "?" # Not a field, just a class attribute
If you want email as a field, do:
@dataclass
class User:
id: int
email: str = "?"4. Mixing Dataclasses and ORM Models
Many ORMs, like SQLAlchemy, have their own base classes and patterns. Directly combining @dataclass and ORM models can be tricky. Often the better pattern is:
- ORM models for database tables.
- Dataclasses for input/output data or domain models.
Summary
- Classes let you group data and behavior, which you will do all the time in backend development.
- Dataclasses reduce boilerplate for classes that mainly hold data.
- Use type hints for all dataclass fields.
- Use
field(default_factory=...)for lists, dicts, and other mutable defaults. __post_init__is useful for validation and derived values after initialization.frozen=Truegives you read only objects, useful for configurations and value objects.- Functions like
asdicthelp convert dataclasses to Python primitives for JSON responses or logging.
Dataclasses fit naturally into backend development, especially for request and response objects, configuration, and small domain models. In later chapters, when you work with FastAPI and Pydantic, you will see similar ideas applied specifically to web APIs.
Views: 10
KAHIBARO