Python dataclasses and Type Hints
AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.
Type hints let editors and CI find mistakes before the code runs, and @dataclass deletes most of the boilerplate from classes that mainly carry data.
Environment
python -m pip install mypy==1.13.0
mypy --version
dataclasses is part of the standard library since Python 3.7.
Annotation syntax
name: str = "Ada"
count: int = 0
def greet(name: str) -> str:
return f"Hi, {name}"
class Config:
retries: int
hosts: list[str]
Containers and unions (built-in generics are preferred on 3.9+):
from collections.abc import Callable, Sequence
def total(values: Sequence[int]) -> int: ...
mapping: dict[str, int]
maybe: int | None = None # same as Optional[int]
pair: tuple[int, str]
handler: Callable[[str], None]
Reusable shapes get aliases:
type UserId = int # 3.12+ type statement
JSONValue = dict[str, "JSONValue"] | list["JSONValue"] | str | int | float | bool | None
Annotations are not enforced at runtime:
x: int = "abc" # runs fine; mypy reports the error
The interpreter stores them in __annotations__ and otherwise ignores them.
Checking with mypy
mypy src/
mypy --strict src/ # requires annotations everywhere
Typical output:
src/app.py:12: error: Argument 1 to "greet" has incompatible type "int"; expected "str"
Configuration in pyproject.toml:
[tool.mypy]
python_version = "3.13"
strict = true
Editors with Pylance or pyright show the same diagnostics inline, which is where most people actually notice them.
dataclass: classes that hold data
By hand:
class Point:
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
def __repr__(self) -> str: ...
def __eq__(self, other: object) -> bool: ...
With a decorator:
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
p = Point(1, 2)
print(p) # Point(x=1, y=2)
print(Point(1, 2) == Point(1, 2)) # True
__init__, __repr__, and __eq__ are generated; field order follows annotation order.
Defaults and default_factory
from dataclasses import dataclass, field
@dataclass
class Team:
name: str
members: list[str] = field(default_factory=list) # new list per instance
created: str = ""
A mutable default must use default_factory. Writing members: list[str] = [] raises ValueError: mutable default <class 'list'> for field members is not allowed — the decorator catches the bug the interpreter would otherwise allow.
Useful options
@dataclass(order=True) # generates __lt__ and friends
class Version:
major: int
minor: int
@dataclass(frozen=True) # immutable and hashable
class Settings:
host: str
port: int = 8000
@dataclass(slots=True) # 3.10+, lower memory, no dynamic attributes
class Row:
id: int
@dataclass
class User:
name: str
slug: str = field(init=False) # not a constructor argument
def __post_init__(self) -> None:
self.slug = "-".join(self.name.lower().split())
frozen=True blocks attribute assignment, which makes the instance safe to share and usable as a dict key. To derive a modified copy, use dataclasses.replace(obj, port=9000).
Conversion helpers
from dataclasses import asdict, astuple, replace
asdict(team) # {'name': ..., 'members': [...]}
astuple(point) # (1, 2)
replace(point, x=9) # Point(x=9, y=2)
asdict recurses into nested dataclasses, so it is a convenient bridge to json.dumps.
dataclass versus NamedTuple
| Need | Choice |
|---|---|
| Hashable, unpackable, dict key | NamedTuple |
| Mutable with defaults and methods | @dataclass |
| Immutable config object | @dataclass(frozen=True) |
| Two or three fields in an intermediate result | plain tuple |
Both give you readable field names; the difference is mutability and the tuple protocol.
Common mistakes
1. Assuming annotations validate data
def add(a: int, b: int) -> int: ...
add("1", "2") # runs, concatenates, no error
For runtime validation use pydantic or explicit checks at the boundary where external data enters.
2. Mutable defaults in a dataclass
Use field(default_factory=list); dict, set, and custom classes have the same problem.
3. Field ordering errors
Fields with defaults must come after fields without, exactly like function parameters.
4. Using Any to silence the checker
Any disables checking for everything it touches. Prefer a precise union; when you must bypass, use a narrow # type: ignore[arg-type] with a comment explaining why.
5. Putting a list inside a frozen dataclass
frozen=True blocks rebinding, not mutation:
@dataclass(frozen=True)
class Settings:
hosts: list[str]
s = Settings(["a"])
s.hosts.append("b") # allowed
Use a tuple or frozenset when the contents must be immutable.
6. Annotating without checking
Hints that no tool verifies drift out of date. Run mypy or pyright in CI, otherwise the annotations are only documentation.
7. Using == on floats inside __eq__
Generated __eq__ compares field by field with ==, so float fields behave like floats. That is usually fine, but not for money.
Summary
- Hints document intent; mypy or pyright enforces them before runtime.
@dataclassgenerates__init__,__repr__, and__eq__; mutable defaults needdefault_factory.frozen=Truefor immutable values,slots=Trueto save memory,order=Truefor sorting.NamedTuplewhen you want tuple behavior, dataclass when you want methods and mutability.
Next: iterators, generators, and lazy pipelines.