Inheritance and Composition with Python Dataclasses
Dataclasses support inheritance and composition, allowing you to build reusable hierarchies and composite structures. However, inheritance in dataclasses has gotchas: field ordering, __init__ generation, and when to use composition instead. Mastering these patterns unlocks powerful, maintainable designs.
In my experience building domain models, composition often wins over inheritance for dataclasses. A User with an embedded Address is cleaner than a User inheriting from ContactInfo. This article shows both approaches and when to use each.
Dataclass Inheritance Basics
When a dataclass inherits from another dataclass, both parent and child fields are included in the generated __init__. Parent fields come first, then child fields:
from dataclasses import dataclass
@dataclass
class Animal:
name: str
age: int
@dataclass
class Dog(Animal):
breed: str
# __init__ signature: Dog(name: str, age: int, breed: str)
dog = Dog(name="Buddy", age=3, breed="Golden Retriever")
print(dog) # Dog(name='Buddy', age=3, breed='Golden Retriever')
Both classes must be decorated with @dataclass for automatic __init__ generation. If you inherit from a non-dataclass, you get no special behavior.
Field Ordering in Inheritance
Fields without defaults must come before fields with defaults. This applies across inheritance:
from dataclasses import dataclass, field
@dataclass
class Vehicle:
make: str
model: str
color: str = "black" # default value
@dataclass
class Car(Vehicle):
doors: int = field(default=4) # defaults come after parent defaults
car = Car(make="Toyota", model="Camry", color="blue", doors=4)
# ERROR: can't add a non-default field after a default
# @dataclass
# class BadCar(Vehicle):
# doors: int # ERROR: no default, but parent has default field!
To work around this, add defaults to the parent or use field(default_factory=...) in the child.
Inheriting from Dataclasses and Plain Classes
You can inherit from a mix of dataclasses and plain classes, but initialization order matters:
from dataclasses import dataclass
class Logger:
def log(self, msg: str) -> None:
print(f"[LOG] {msg}")
@dataclass
class Person(Logger):
name: str
age: int
def __post_init__(self) -> None:
self.log(f"Created person: {self.name}")
person = Person("Alice", 30) # [LOG] Created person: Alice
The dataclass decorator handles initialization of its own fields; the plain parent class's __init__ is not called automatically. If the parent needs initialization, call super().__init__() in your __post_init__:
from dataclasses import dataclass
class Connection:
def __init__(self, host: str):
self.host = host
self.connected = False
def connect(self) -> None:
self.connected = True
@dataclass
class Database(Connection):
db_name: str
def __post_init__(self) -> None:
super().__init__(self.host) # Initialize parent
self.connect()
Wait—this won't work because self.host doesn't exist yet. Better approach: use a dataclass with composition or call super().__init__() before using parent state.
Abstract Base Classes with Dataclasses
You can define abstract dataclasses using abc.ABC and @abstractmethod:
from dataclasses import dataclass
from abc import ABC, abstractmethod
@dataclass
class Shape(ABC):
name: str
@abstractmethod
def area(self) -> float:
pass
@dataclass
class Circle(Shape):
radius: float
def area(self) -> float:
return 3.14159 * self.radius ** 2
# Cannot instantiate abstract Shape
# shape = Shape("generic") # TypeError: Can't instantiate abstract class Shape
circle = Circle(name="MyCircle", radius=5.0)
print(circle.area()) # 78.53975
Abstract dataclasses are useful for defining common structure across domain models.
Composition Over Inheritance
Composition is often cleaner and more flexible than inheritance. Embed a dataclass as a field instead of inheriting:
from dataclasses import dataclass, field
@dataclass
class Address:
street: str
city: str
postal_code: str
@dataclass
class Person:
name: str
age: int
address: Address # Composition: Address is a field
alice = Person(
name="Alice",
age=30,
address=Address(
street="123 Main St",
city="New York",
postal_code="10001"
)
)
print(f"{alice.name} lives in {alice.address.city}")
Composition advantages:
- No field-ordering constraints.
- A person can have multiple addresses (list of
Address). Addressis reusable in other contexts (Company.address,Branch.address).- Easier to test in isolation.
Validation in Inheritance Hierarchies
When subclasses have __post_init__, it runs after the full __init__ (parent fields + child fields). Call super().__post_init__() to ensure parent validation runs:
from dataclasses import dataclass
@dataclass
class Animal:
name: str
age: int
def __post_init__(self) -> None:
if not self.name:
raise ValueError("name is required")
if self.age < 0:
raise ValueError("age cannot be negative")
@dataclass
class Dog(Animal):
breed: str
def __post_init__(self) -> None:
super().__post_init__() # Validate parent fields first
if not self.breed:
raise ValueError("breed is required")
dog = Dog(name="Buddy", age=3, breed="Labrador")
# Invalid breed
try:
bad = Dog(name="Unknown", age=2, breed="")
except ValueError as e:
print(e) # breed is required
Mixin Patterns with Dataclasses
Use mixins to add functionality to dataclasses without deep inheritance:
from dataclasses import dataclass
from typing import Any
class DictMixin:
def to_dict(self) -> dict[str, Any]:
return {k: v for k, v in self.__dict__.items()}
class ReprMixin:
def __custom_repr__(self) -> str:
fields = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items())
return f"{self.__class__.__name__}({fields})"
@dataclass
class Config(DictMixin):
host: str
port: int
debug: bool = False
config = Config(host="localhost", port=8000)
print(config.to_dict()) # {'host': 'localhost', 'port': 8000, 'debug': False}
Mixins are more flexible than multiple inheritance and avoid the complexity of diamond inheritance.
Comparison: Inheritance vs. Composition
| Use Case | Pattern | Example |
|---|---|---|
| Shared structure + behavior | Inheritance | Employee(Person): all employees are persons |
| Multiple variants of a type | Inheritance | Manager(Employee), Contractor(Employee) |
| "A has a" relationships | Composition | Person.address: Address |
| Optional or repeated nested data | Composition | Order.items: list[OrderItem] |
| Avoiding field-ordering constraints | Composition | Use composition if parent has defaults |
Real-World Example: Domain Model Hierarchy
from dataclasses import dataclass, field
from datetime import datetime
from abc import ABC, abstractmethod
@dataclass
class AuditTrail:
created_at: datetime = field(default_factory=datetime.now)
updated_at: datetime = field(default_factory=datetime.now)
@dataclass
class Entity(ABC):
id: int
audit: AuditTrail = field(default_factory=AuditTrail)
@abstractmethod
def validate(self) -> bool:
pass
@dataclass
class User(Entity):
email: str
username: str
def validate(self) -> bool:
return "@" in self.email and len(self.username) >= 3
@dataclass
class Post(Entity):
title: str
body: str
author_id: int
def validate(self) -> bool:
return len(self.title) > 0 and len(self.body) > 0
user = User(id=1, email="[email protected]", username="alice")
post = Post(id=100, title="My Post", body="Content", author_id=1)
print(user.validate()) # True
print(post.audit.created_at) # 2026-06-02 ...
Key Takeaways
- Child dataclasses inherit parent fields in
__init__; parent fields come first. - Non-default fields must come before default fields, even across inheritance.
- Call
super().__post_init__()in child__post_init__to run parent validation. - Use composition (embedding dataclasses as fields) when inheritance adds complexity.
- Abstract dataclasses enforce contracts across a domain model.
- Mixins provide functionality without deep inheritance hierarchies.
Frequently Asked Questions
Can a child dataclass have fewer fields than its parent?
No, you cannot remove parent fields. Child classes must include all parent fields plus their own.
How do I override a parent field with a different default?
You cannot override a field type or default in a child dataclass; Python will raise an error. Instead, use composition or define a new field with a different name.
Can I mix dataclass and non-dataclass inheritance?
Yes, but the non-dataclass parent's __init__ is not called automatically. Call super().__init__() in __post_init__ if needed.
Should I always use composition over inheritance?
No. Use inheritance for is-a relationships (an Employee is a Person) and composition for has-a relationships (a Person has an Address). Judge case-by-case.