Filter concepts by levelShowing all levels.

Python · Section 10

Dataclasses and Data Modeling

Level
intermediate
Read
55 min
Concepts
8

Generating a data-holding class's __init__, __repr__, and __eq__ from a field list instead of writing them by hand — how to make one immutable, order its instances, give a field a safe default, and know when a dataclass is the wrong tool.

Python overview

What is true here

  1. @dataclass generates __init__, __repr__, and __eq__ from type-annotated fields — it never validates those types.
  2. A mutable default value (list, dict, set) is rejected at class-creation time; field(default_factory=...) is the fix.
  3. __post_init__ runs automatically as the last step of the generated __init__, after every field is already assigned.
  4. frozen=True makes an instance immutable after construction — the usual shape of a value object.
  5. A dataclass generates boilerplate; Pydantic additionally validates and coerces values at construction time.

What you will be able to do

  • Generate __init__, __repr__, and __eq__ from a plain field list instead of writing them by hand
  • Give a mutable field a safe default with field(default_factory=...), and explain why a plain [] is rejected
  • Use __post_init__ to validate a field or compute one derived from the others
  • Make a dataclass immutable and orderable, and build a dataclass subclass whose fields combine correctly
  • Decide between a regular class, a dataclass, and a Pydantic model for a given piece of data

The basics

What @dataclass generates, and the one keyword argument that makes an instance immutable.

@dataclass

coreintermediate

@dataclass reads a class's type-annotated attributes and generates __init__, __repr__, and __eq__ for you. You still write the attribute list; Python writes the boilerplate methods that normally go with it.

Think of it as

A plain class is a blank form you fill in by hand every time — write __init__ to accept the fields, write __repr__ to print them, write __eq__ to compare them. @dataclass reads the field list once and prints all three forms for you, correctly, from that one list.

python
from dataclasses import dataclass

@dataclass
class Order:
    order_id: str
    total_cents: int
    is_paid: bool = False   # a default value, same rules as a function default

What we're doing: Confirm @dataclass actually generates a working __init__, __repr__, and __eq__ from three annotated fields, and contrast that with the same class written by hand.

order.pypython
from dataclasses import dataclass


@dataclass
class Order:
    order_id: str
    total_cents: int
    is_paid: bool = False


o1 = Order("A100", 2599)
o2 = Order("A100", 2599)
o3 = Order("A101", 500)

print(repr(o1))
print(o1 == o2)
print(o1 == o3)


class PlainOrder:
    def __init__(self, order_id, total_cents, is_paid=False):
        self.order_id = order_id
        self.total_cents = total_cents
        self.is_paid = is_paid


p1 = PlainOrder("A100", 2599)
p2 = PlainOrder("A100", 2599)
print(repr(p1))
print(p1 == p2)
4
@dataclass reads the three annotations below it and builds __init__, __repr__, and __eq__ from them — nothing else in the class body is required.
15
repr(o1) shows every field by name, generated from the annotation order — a plain class would print a memory address instead.
16
o1 == o2 compares field by field and is True, even though they are two separate objects — a plain class compares by identity instead.
Output
Order(order_id='A100', total_cents=2599, is_paid=False)
True
False
<__main__.PlainOrder object at 0x000001A8F3912120>
False

Why this works: @dataclass generates __init__, __repr__, and __eq__ once, from the field list, at class-creation time — it does not change what Python objects fundamentally are. PlainOrder, without any of the three defined, falls back to object's defaults: a memory-address repr and identity-based equality, which is why p1 == p2 is False even though every field matches.

Expecting @dataclass to validate types at runtime

Wrong

python
@dataclass
class Order:
    order_id: str
    total_cents: int

order = Order("A100", "not a number")  # no error!
print(order.total_cents)  # "not a number" — the string, unchanged

Better

python
@dataclass
class Order:
    order_id: str
    total_cents: int

    def __post_init__(self):
        if not isinstance(self.total_cents, int):
            raise TypeError(f"total_cents must be int, got {type(self.total_cents).__name__}")

order = Order("A100", "not a number")  # raises TypeError

What you see: No error at construction time — total_cents silently holds a str instead of an int, and the bug surfaces later wherever the code assumes it is a number.

Why: total_cents: int is a type hint, read by a type checker like mypy, not a runtime check. @dataclass only uses the annotation to know a field exists and what order it comes in — it never inspects the value passed in. Real runtime validation needs an explicit check, most often in __post_init__, or a library built for it (see Dataclasses vs Pydantic models).

What @dataclass generates from three annotated fields

Annotated fields

order_id: str, total_cents: int, is_paid: bool = False

@dataclass reads them

at class-creation time, once

__init__, __repr__, __eq__

generated and attached to the class

  1. Annotated fields — order_id: str, total_cents: int, is_paid: bool = False
  2. @dataclass reads them — at class-creation time, once
  3. __init__, __repr__, __eq__ — generated and attached to the class

Remember: @dataclass generates __init__, __repr__, and __eq__ from type-annotated fields — it never validates or enforces those types at runtime.

See also: frozen dataclasses · default values and field · equality dunders

Frozen dataclasses

coreintermediate

@dataclass(frozen=True) blocks assigning to any field after construction. Attempting obj.field = value raises dataclasses.FrozenInstanceError instead of silently changing the object.

Think of it as

A regular dataclass instance is a form you can keep editing after signing it. frozen=True photocopies the signed form under glass — every field is still readable, but the class itself refuses any further edit, at the moment you try to make one, not later.

python
from dataclasses import dataclass

@dataclass(frozen=True)
class Coordinates:
    lat: float
    lon: float

What we're doing: Confirm frozen=True actually raises on a mutation attempt, and capture the exact exception type and message.

coordinates.pypython
from dataclasses import dataclass, FrozenInstanceError


@dataclass(frozen=True)
class Coordinates:
    lat: float
    lon: float


c = Coordinates(51.5, -0.1)
print(c)

try:
    c.lat = 10.0
except FrozenInstanceError as e:
    print(type(e).__name__, "-", e)
    print(isinstance(e, AttributeError))
4
frozen=True is the only change from a regular @dataclass — the field list underneath is unchanged.
14
c.lat = 10.0 looks like a normal attribute assignment, but frozen=True intercepts it and raises before the value is ever stored.
Output
Coordinates(lat=51.5, lon=-0.1)
FrozenInstanceError - cannot assign to field 'lat'
True

Why this works: frozen=True adds a __setattr__ override that raises dataclasses.FrozenInstanceError for every assignment after the object exists — __init__ is given a special path around this override so construction still works exactly once. FrozenInstanceError subclasses AttributeError, so existing code that catches AttributeError still catches it.

Trying to mutate a frozen dataclass field inside its own __post_init__

Wrong

python
@dataclass(frozen=True)
class Circle:
    radius: float

    def __post_init__(self):
        self.radius = abs(self.radius)  # FrozenInstanceError — even here!

Circle(-5)

Better

python
@dataclass(frozen=True)
class Circle:
    radius: float

    def __post_init__(self):
        object.__setattr__(self, "radius", abs(self.radius))

Circle(-5)

What you see: FrozenInstanceError: cannot assign to field 'radius' — raised from inside __post_init__ itself, which is easy to assume is exempt since it still runs during construction.

Why: frozen=True's __setattr__ override has no special case for __post_init__ — only __init__'s own generated assignments are exempt. Normalizing or computing a field inside __post_init__ on a frozen dataclass needs object.__setattr__(self, name, value), which bypasses the class's own __setattr__ entirely — the one documented, intentional escape hatch.

Remember: frozen=True raises FrozenInstanceError (an AttributeError subclass) on any assignment after construction — use object.__setattr__ inside __post_init__.

See also: dataclass basics · post init processing · dataclasses vs alternatives

Advertisement

Controlling behavior

Defaults that are actually safe, code that runs after construction, ordering, inheritance, and trading __dict__ for fixed slots.

Default values, default_factory, and field()

standardintermediate

field: type = value gives a field a plain default. A mutable default like [] is rejected at class-creation time — field(default_factory=list) calls a zero-argument function per instance instead, avoiding a shared, silently-mutated default.

Think of it as

A plain default (= 0, = "") is a value stamped once and copied by reference into every instance — fine for something immutable, since it cannot be edited from under another instance anyway. A mutable default would be one shared object every instance points at, which @dataclass refuses outright; default_factory is a small factory that runs once per instance instead, so each one gets its own list.

python
from dataclasses import dataclass, field

@dataclass
class Cart:
    items: list = field(default_factory=list)   # NOT items: list = []

What we're doing: Reproduce the mutable-default-argument bug on a dataclass field, capture the exact error, and show default_factory avoiding it.

cart.pypython
from dataclasses import dataclass, field

try:
    exec(compile("""
from dataclasses import dataclass

@dataclass
class Cart:
    items: list = []
""", "<broken>", "exec"))
except ValueError as e:
    print("ValueError:", e)


@dataclass
class Cart:
    items: list = field(default_factory=list)


cart_a = Cart()
cart_b = Cart()
cart_a.items.append("widget")
print(cart_a.items, cart_b.items, cart_a.items is cart_b.items)
4
items: list = [] is compiled and executed to trigger the real class-creation error, without that line breaking the surrounding file that imports this example.
16
field(default_factory=list) replaces the plain default — list is a callable, called fresh for each new Cart, not a shared literal.
Output
ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory
['widget'] [] False

Why this works: @dataclass detects a mutable default (list, dict, set) at class-creation time and refuses to build the class — this is the same shared-default bug a plain function's def f(items=[]) has, caught early instead of discovered later as a bug. default_factory=list stores the callable list itself, not its result, and calls it once per Cart() — cart_a.items is cart_b.items being False confirms each instance got its own list.

Assuming field(default=[]) works around the mutable-default check

Wrong

python
@dataclass
class Cart:
    items: list = field(default=[])   # still ValueError — same rule applies

Better

python
@dataclass
class Cart:
    items: list = field(default_factory=list)   # the only way to default a mutable field

What you see: ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory — field() does not exempt a mutable value passed through default=.

Why: field(default=value) and field: type = value follow the identical rule — @dataclass checks the actual object for list, dict, or set (and any dataclass instance) regardless of which syntax supplied it. default_factory is the only parameter that defers construction to instance-creation time instead of storing one shared object up front.

field() keyword arguments

field() keyword arguments
ArgumentDefaultEffect
defaultMISSINGA plain default value — mutually exclusive with default_factory
default_factoryMISSINGA zero-argument callable run once per instance to produce the default
initTrueFalse excludes the field from the generated __init__ parameter list
reprTrueFalse excludes the field from the generated __repr__ output
compareTrueFalse excludes the field from the generated __eq__/ordering comparisons
metadata{}A read-only mapping for third-party tools — @dataclass itself ignores it

Together

python
from dataclasses import dataclass, field, fields

@dataclass
class Employee:
    name: str
    salary: float = field(default=50000.0, repr=False)
    tags: list = field(default_factory=list, compare=False)

e1 = Employee("Dana")
e2 = Employee("Dana", tags=["eng"])
print(repr(e1))
print(e1 == e2)   # tags excluded from compare -> still equal

Remember: A plain default is fine for immutable values; a mutable one raises ValueError at class-creation time — use field(default_factory=list) instead.

See also: dataclass basics · post init processing · mutable vs immutable

Post-init processing

standardintermediate

If a dataclass defines __post_init__(self), the generated __init__ calls it automatically as its last step, after every field is already assigned. It is where validation or a computed field belongs.

Think of it as

The generated __init__ is a checklist: assign field 1, assign field 2, ... then, if __post_init__ exists, run it last. Writing __post_init__ is adding one final step to that checklist without having to rewrite the whole thing by hand.

python
from dataclasses import dataclass, field

@dataclass
class Rectangle:
    width: float
    height: float
    area: float = field(init=False)   # not passed in — computed instead

    def __post_init__(self):
        self.area = self.width * self.height

What we're doing: Confirm __post_init__ runs after every field is assigned, computing a derived field and validating another.

shapes.pypython
from dataclasses import dataclass, field


@dataclass
class Rectangle:
    width: float
    height: float
    area: float = field(init=False)

    def __post_init__(self):
        self.area = self.width * self.height


r = Rectangle(3, 4)
print(r)


@dataclass
class Percentage:
    value: float

    def __post_init__(self):
        if not 0 <= self.value <= 100:
            raise ValueError(f"value must be 0-100, got {self.value}")


try:
    Percentage(150)
except ValueError as e:
    print("ValueError:", e)
print(Percentage(50))
7
area: float = field(init=False) removes area from __init__'s parameter list — it has no default value yet, so it must be set somewhere before the object is considered built.
9
__post_init__ runs last, after width and height are already on self — self.width and self.height are both safe to read here.
Output
Rectangle(width=3, height=4, area=12)
ValueError: value must be 0-100, got 150
Percentage(value=50)

Why this works: The generated __init__ assigns width, height, then area=field(init=False) leaves area unset until __post_init__ runs and computes it from the two fields that already exist on self. Percentage(150) raises inside __post_init__, after value=150 is already assigned, which is why the check reads self.value rather than a parameter — validation runs as the true last step of construction, not before it.

Forgetting field(init=False) on a field only __post_init__ should set

Wrong

python
@dataclass
class Rectangle:
    width: float
    height: float
    area: float   # no default -> caller MUST pass it, defeating the point

    def __post_init__(self):
        self.area = self.width * self.height

r = Rectangle(3, 4, 0)  # caller has to pass a throwaway value

Better

python
@dataclass
class Rectangle:
    width: float
    height: float
    area: float = field(init=False)   # excluded from __init__'s parameters

    def __post_init__(self):
        self.area = self.width * self.height

r = Rectangle(3, 4)  # area is computed, never passed

What you see: Without init=False, area becomes a required __init__ parameter with no default — every caller has to pass a value for a field __post_init__ immediately overwrites, which is confusing and easy to get wrong.

Why: field(init=False) is what tells @dataclass "do not put this field in the generated __init__ signature at all" — without it, any field with no default is a required constructor argument, whether or not __post_init__ later recomputes it.

Remember: __post_init__(self) runs as the last step of the generated __init__, once every field is assigned — use it for validation or a field(init=False) value.

See also: default values and field · frozen dataclasses · dataclass basics

Ordering

standardintermediate

@dataclass(order=True) generates __lt__, __le__, __gt__, and __ge__, comparing instances field by field in declaration order — like comparing the tuples of their values. Without it, < and > raise TypeError.

Think of it as

order=True treats two instances like two tuples of their field values and compares them the way Python already compares tuples: first field decides, and only ties fall through to the next one. Reorder the fields and you reorder what "greater" means.

python
from dataclasses import dataclass

@dataclass(order=True)
class Version:
    major: int
    minor: int
    patch: int

What we're doing: Confirm order=True enables real <, >, and sorted() behavior, and that omitting it raises TypeError instead.

version.pypython
from dataclasses import dataclass


@dataclass(order=True)
class Version:
    major: int
    minor: int
    patch: int


v1 = Version(1, 2, 0)
v2 = Version(1, 3, 0)
print(v1 < v2, v1 > v2)
print(sorted([v2, v1]))


@dataclass
class NoOrder:
    x: int


try:
    NoOrder(1) < NoOrder(2)
except TypeError as e:
    print("TypeError:", e)
4
order=True is the only addition needed — Python compares (major, minor, patch) tuples under the hood, the same way it compares two literal tuples.
13
sorted() calls __lt__ internally, which is why order=True is enough to make a list of Version instances sortable with no extra code.
Output
True False
[Version(major=1, minor=2, patch=0), Version(major=1, minor=3, patch=0)]
TypeError: '<' not supported between instances of 'NoOrder' and 'NoOrder'

Why this works: order=True generates the four comparison methods by building a tuple of each instance's fields, in declaration order, and comparing those tuples — v1 < v2 is really (1, 2, 0) < (1, 3, 0), which Python resolves by the first differing field. NoOrder never got that generated code, so < falls back to the default object behavior, which has no ordering defined at all and raises TypeError.

Assuming order=True works alongside eq=False

Wrong

python
@dataclass(eq=False, order=True)   # ValueError at class-creation time
class Version:
    major: int
    minor: int

Better

python
@dataclass(order=True)   # eq defaults to True — leave it alone
class Version:
    major: int
    minor: int

What you see: ValueError: eq must be true if order is true — raised the moment the class is defined, before any instance exists.

Why: The ordering methods are defined in terms of the same field tuple __eq__ already compares — @dataclass refuses to generate __lt__/__le__/__gt__/__ge__ without __eq__ also present, since "ordered but not comparable for equality" would be an inconsistent object model.

Remember: @dataclass(order=True) compares instances field by field, in declaration order, like tuples — without it, < and > raise TypeError.

See also: dataclass basics · less than le dunders · greater than ge dunders

Dataclass inheritance

standardintermediate

A dataclass subclassing another dataclass inherits its fields, in order, before its own. The combined field list still needs every no-default field before every field with a default, across both classes.

Think of it as

Subclassing a dataclass is gluing the parent's field list to the front of the child's own, then generating one __init__ from the combined list — not two separate constructors calling each other. The parent's fields keep their order and their defaults; the child just adds more after them.

python
from dataclasses import dataclass

@dataclass
class Animal:
    name: str
    sound: str = "..."

@dataclass
class Dog(Animal):
    breed: str = "mixed"   # must also have a default -- sound already does

What we're doing: Confirm a dataclass subclass inherits and orders parent fields correctly, and reproduce the field-ordering error when a required subclass field follows a defaulted parent field.

animals.pypython
from dataclasses import dataclass, fields


@dataclass
class Animal:
    name: str
    sound: str = "..."


@dataclass
class Dog(Animal):
    breed: str = "mixed"


d = Dog(name="Rex", breed="Labrador")
print(d)
print([f.name for f in fields(Dog)])

try:
    exec(compile("""
from dataclasses import dataclass

@dataclass
class Base:
    a: int = 1

@dataclass
class Sub(Base):
    b: int
""", "<bad_inherit>", "exec"))
except TypeError as e:
    print("TypeError:", e)
10
breed: str = "mixed" must also carry a default, because Animal.sound already has one — a required field cannot follow a defaulted one anywhere in the combined list.
16
fields(Dog) shows name and sound (inherited, in Animal's order) before breed (Dog's own) — confirming the combined-list order.
Output
Dog(name='Rex', sound='...', breed='Labrador')
['name', 'sound', 'breed']
TypeError: non-default argument 'b' follows default argument 'a'

Why this works: Dog's generated __init__ takes Animal's fields first (name, sound) then Dog's own (breed) — exactly the fields(Dog) order printed. The second snippet fails for the same reason a single class with fields in that order would: Sub inherits Base.a (which has a default) then declares its own b with no default, and @dataclass rejects any required field that comes after a defaulted one in the combined, flattened list.

Assuming a dataclass subclass gets a separate, independent __init__

Wrong

python
@dataclass
class Animal:
    name: str

    def __init__(self, name):   # hand-written -- fights the generated one
        self.name = name.title()

@dataclass
class Dog(Animal):
    breed: str

Dog("rex", "Labrador")  # TypeError -- Animal's hand-written __init__ took over

Better

python
@dataclass
class Animal:
    name: str

    def __post_init__(self):
        self.name = self.name.title()   # normalize without replacing __init__

@dataclass
class Dog(Animal):
    breed: str

Dog("rex", "Labrador")  # works -- one combined, generated __init__

What you see: Writing __init__ by hand on the parent class silences @dataclass's generated one, breaking the single combined-__init__ story the subclass depends on — the subclass's own generated __init__ still expects the parent's fields to already be handled the generated way.

Why: @dataclass only generates __init__ if the class does not already define one — a hand-written __init__ on a parent dataclass replaces the piece the subclass's own field-combining logic assumes exists. __post_init__ is the supported hook for per-field logic that still leaves __init__ itself generated.

Remember: A dataclass subclass gets one combined __init__, parent fields first — the no-default-before-default rule spans the whole combined list, not one class.

See also: default values and field · post init processing · inheritance

slots=True

standardintermediate

@dataclass(slots=True), added in Python 3.10, generates __slots__ from the field list automatically — no __dict__, lower memory, and AttributeError on any attribute not declared as a field.

Think of it as

Writing __slots__ by hand means listing every attribute name yourself and keeping that list in sync with __init__. slots=True reads the field list @dataclass already has and generates the exact same __slots__ tuple from it — one less list to keep in sync, at the cost of a rebuilt class.

python
from dataclasses import dataclass

@dataclass(slots=True)
class Point3D:
    x: float
    y: float
    z: float

What we're doing: Confirm slots=True removes __dict__ and blocks an undeclared attribute, the same guarantee hand-written __slots__ gives, generated instead of authored.

point3d.pypython
from dataclasses import dataclass


@dataclass(slots=True)
class Point3D:
    x: float
    y: float
    z: float


pt = Point3D(1, 2, 3)
print(pt)
print(hasattr(pt, "__dict__"))

try:
    pt.w = 4
except AttributeError as e:
    print("AttributeError:", e)
4
slots=True is the only change from a plain @dataclass — x, y, z are still declared exactly the same way.
16
pt.w = 4 fails because w was never one of the three fields @dataclass used to build __slots__ from.
Output
Point3D(x=1, y=2, z=3)
False
AttributeError: 'Point3D' object has no attribute 'w' and no __dict__ for setting new attributes

Why this works: slots=True tells @dataclass to build a __slots__ tuple from x, y, and z, then construct the class using that tuple — the same mechanism hand-written __slots__ = ("x", "y", "z") would set up, just generated from the field list instead of retyped. hasattr(pt, "__dict__") is False for exactly the reason it would be on any __slots__ class, and pt.w = 4 is rejected the same way.

Adding a default mutable value via a plain class attribute under slots=True

Wrong

python
@dataclass(slots=True)
class Config:
    name: str
    tags: list = []   # ValueError -- same mutable-default rule as any dataclass

Better

python
from dataclasses import field

@dataclass(slots=True)
class Config:
    name: str
    tags: list = field(default_factory=list)

What you see: ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory — slots=True does not change or bypass this check.

Why: slots=True only changes how attributes are stored on the instance (slots instead of __dict__) — it has no effect on field defaults, which are still checked the same way as in any other dataclass. See default values, default_factory, and field().

Remember: slots=True (3.10+) generates __slots__ from the dataclass's own fields — same no-__dict__ guarantee as hand-written __slots__, without a second list to sync.

See also: slots · frozen dataclasses · dataclasses vs alternatives

Advertisement

When to reach for a dataclass

Weighing a dataclass against a regular class and a validating Pydantic model, and the value-object pattern a frozen dataclass usually serves.

Dataclasses vs regular classes vs Pydantic models, and value objects

standardintermediate

A dataclass trades a small amount of control for generated __init__/__repr__/__eq__ — reach for a regular class when you need more control over construction, and Pydantic when you need runtime validation, not just type hints.

Think of it as

Three tools answering "how do I hold a bundle of related data," in increasing order of what they do FOR you: a regular class does nothing until you write it; a dataclass generates the boilerplate but trusts the types you wrote; Pydantic actually checks and converts values against those types at runtime, at the cost of a dependency and some speed.

python
from dataclasses import dataclass

@dataclass(frozen=True, slots=True)   # a value object: immutable, compared by value
class Money:
    amount_cents: int
    currency: str

What we're doing: Show a dataclass silently accepting a wrong-typed value where Pydantic rejects it, then build a value object from a frozen dataclass.

money.pypython
from dataclasses import dataclass, FrozenInstanceError


@dataclass(frozen=True, slots=True)
class Money:
    amount_cents: int
    currency: str

    def __post_init__(self):
        if self.amount_cents < 0:
            raise ValueError("amount_cents cannot be negative")


price = Money(1999, "USD")
price2 = Money(1999, "USD")
print(price == price2, price is price2)

try:
    price.amount_cents = 0
except FrozenInstanceError as e:
    print("FrozenInstanceError:", e)
4
frozen=True and slots=True together are the standard shape of a value object: no mutation after construction, no accidental extra attributes.
16
price == price2 is True even though they are two separate objects — a value object is defined by comparing equal on VALUE, exactly what @dataclass's generated __eq__ already gives for free.
Output
True False
FrozenInstanceError: cannot assign to field 'amount_cents'

Why this works: price is price2 being False while price == price2 is True is the entire point of a value object: two Money instances holding the same amount and currency ARE equal, without being the same object in memory — unlike an Order or a User, which usually compares by an identity such as an id, a Money amount has no identity beyond its own value. frozen=True then guarantees that value can never silently drift after construction.

Reaching for a dataclass to validate data from outside the program

Wrong

python
@dataclass
class SignupRequest:
    email: str
    age: int

# request body from an HTTP API, already parsed as a dict
data = {"email": "user@example.com", "age": "twelve"}
req = SignupRequest(**data)   # builds successfully -- age is the STRING "twelve"!

Better

python
from pydantic import BaseModel

class SignupRequest(BaseModel):
    email: str
    age: int

data = {"email": "user@example.com", "age": "twelve"}
req = SignupRequest(**data)   # raises pydantic.ValidationError -- age cannot become an int

What you see: SignupRequest(**data) succeeds with age holding the string "twelve" instead of a number — the bug surfaces later, wherever that field is used as an int.

Why: @dataclass only reads type annotations to build __init__'s parameter list — it never checks or converts the value passed in. Data arriving from outside the program (an HTTP request body, a config file, a CSV row) needs a tool that actually validates and coerces at the boundary; that is what Pydantic is for, and why the two are not interchangeable despite looking similar.

Regular class vs @dataclass vs Pydantic BaseModel

Regular class vs @dataclass vs Pydantic BaseModel
PropertyRegular class@dataclassPydantic BaseModel
__init__/__repr__/__eq__written by handgeneratedgenerated
Runtime type validationnone, unless you add itnone — hints are uncheckedyes — raises ValidationError
Coerces input ("3" -> 3)nonoyes, by default
Extra dependencynono (stdlib)yes (pydantic)
Typical usecustom construction logicinternal data holdersdata crossing a trust boundary (API input, config, files)

Together

python
from dataclasses import dataclass
from pydantic import BaseModel, ValidationError

@dataclass
class OrderDC:
    total_cents: int

print(OrderDC(total_cents="oops"))  # no error -- stores the string as-is

class OrderModel(BaseModel):
    total_cents: int

try:
    OrderModel(total_cents="oops")
except ValidationError as e:
    print(e.error_count(), "validation error(s)")

Remember: A dataclass generates boilerplate but never validates; Pydantic validates and coerces at construction. A value object is compared by value, not identity.

See also: frozen dataclasses · dataclass slots · equality dunders · classes and objects

Advertisement