dataclasses vs Pydantic v2 vs attrs
Three competing ways to declare “a class with named fields” in modern Python. They look similar; they solve different problems. Picking the right one is part of the job.
The TL;DR
| dataclasses | Pydantic v2 | attrs | |
|---|---|---|---|
| Source | stdlib | third-party | third-party |
| Validation | none (type hints aren’t enforced) | yes — runtime | yes (via converters / validators) |
| Serialization | manual or asdict |
first-class JSON | manual (cattrs is separate) |
| Perf | fast (just init/repr/eq generated) | slower (validation cost) | fast |
| Type hint role | documentation only | enforced at runtime | optional (with @attr.s(auto_attribs=True)) |
| FastAPI / OpenAPI | minimal | first-class | not directly |
| Use case | simple data containers | API boundaries, config | rich domain models, library code |
dataclasses
Standard library since 3.7. @dataclass generates __init__, __repr__, __eq__ (and optionally more) from class-level annotations.
from dataclasses import dataclass, field
@dataclass
class Order:
id: int
items: list[str] = field(default_factory=list)
discount: float = 0.0
Pros:
- Zero dependencies.
- Lightweight; just class-generation magic.
- Standard.
Cons:
- No runtime validation.
Order(id="not an int", items="not a list")works fine; types are documentation. - No serialization (
dataclasses.asdictexists, but no JSON support, no schema generation). - No reverse parsing (JSON → dataclass).
- Limited to basic types in
default_factory.
@dataclass(frozen=True) makes instances immutable. @dataclass(slots=True) (3.10+) uses __slots__ for memory savings and attribute restriction.
Use for: simple in-process data containers where types are just hints. Internal-only “value object” classes.
Pydantic v2
Schema-driven data validation library. Replaces dataclasses’ role at API boundaries with type-checked runtime parsing.
from pydantic import BaseModel, Field, field_validator
class OrderIn(BaseModel):
id: int
items: list[str] = Field(default_factory=list)
discount: float = Field(default=0.0, ge=0, le=1)
@field_validator("items")
@classmethod
def items_not_empty(cls, v):
if not v:
raise ValueError("items must not be empty")
return v
Behavior:
- Constructing
OrderIn(id="42")parses the string"42"to int 42 — coercion happens. - Constructing
OrderIn(id="not a number")raises a clearValidationError. .model_dump()→ dict;.model_dump_json()→ JSON string.OrderIn.model_validate(dict_from_json)parses arbitrary data.- Generates JSON Schema via
.model_json_schema().
Pros:
- Real validation. Catches type errors at the boundary.
- JSON serialization built in.
- Schema generation — basis for FastAPI’s OpenAPI.
- v2 is fast (Rust-backed
pydantic-core). - Rich coercion rules.
computed_fieldfor derived values.
Cons:
- Heavier than dataclasses for in-process structures.
- v1 → v2 migration was painful (old code still around).
- Validation cost adds up on hot paths.
Use for: API request/response models, config files, anything that crosses a trust boundary, anything that needs JSON Schema.
attrs
Pre-dates dataclasses; richer feature set; still maintained.
import attrs
@attrs.define
class Order:
id: int
items: list[str] = attrs.field(factory=list)
discount: float = 0.0
@discount.validator
def _check_discount(self, attribute, value):
if not 0 <= value <= 1:
raise ValueError("discount must be 0..1")
Features dataclasses lacks:
- Validators — runtime validation per field, called in
__init__. - Converters — transform input on assignment (e.g.,
str → datetime). evolve()— create a modified copy:attrs.evolve(order, discount=0.1). Cleaner than dataclasses’replace.- Aliases / hidden fields —
attrs.field(init=False),alias="x". - Slotted by default with
@attrs.define. attrs.frozendecorator for immutability.
cattrs is the partner library for structuring / unstructuring (similar to Pydantic’s parsing). Splits validation (attrs) from serialization (cattrs).
Pros:
- More features than dataclasses without Pydantic’s heaviness.
- Slotted by default — memory efficient.
- Better for library authors (more control).
Cons:
- Third-party.
- Smaller ecosystem than Pydantic for HTTP/API stuff.
- Two libraries (attrs + cattrs) for parity with Pydantic’s one.
Use for: rich internal domain models, library APIs where you want validation + immutability + slots without the Pydantic dependency.
Performance comparison
For a struct with 5 fields constructed in a tight loop:
| Library | Relative speed |
|---|---|
@dataclass(slots=True) |
1.0× (baseline) |
@attrs.define (slots default) |
~1.1× |
| Pydantic v2 (validation on) | ~3-5× slower |
Pydantic v2 with model_construct() (skip validation) |
~1.5× |
| Pydantic v1 | ~10× slower than v2 |
Pydantic v2 is significantly faster than v1 because of the Rust core. But validation isn’t free — for “I already trust this data, just need a struct,” dataclasses or attrs win.
model_construct(...) skips validation and is fast — use when you’ve already validated upstream.
Coercion vs strict typing
Pydantic v2 coerces by default:
OrderIn(id="42").id # 42 (int)
OrderIn(id="abc").id # ValidationError
If you want strict matching (no coercion):
from pydantic import BaseModel, ConfigDict
class StrictOrder(BaseModel):
model_config = ConfigDict(strict=True)
id: int
StrictOrder(id="42") # ValidationError — strict refuses string-to-int
Or per field with Field(strict=True).
Coercion is often what you want at HTTP boundaries (query strings are strings); strict is what you want for internal cross-service boundaries.
When to use dataclass over Pydantic
- Pure internal data with trusted source.
- Performance-sensitive hot path.
- Lots of instances (memory matters — use
slots=True). - No JSON serialization or schema generation needed.
- Want zero dependencies.
When to use Pydantic over dataclass
- HTTP request / response models.
- Configuration loaded from files / env (use
pydantic-settings). - Anything that needs JSON Schema (OpenAPI generation).
- Validation across trust boundaries.
- Working with FastAPI (it’s built on Pydantic).
When to use attrs over both
- Library code where you don’t want to force Pydantic on consumers.
- Rich domain models with custom validators + converters.
- Memory-sensitive without the Pydantic overhead.
- You want immutability (
@attrs.frozen).
Migration patterns
dataclass → Pydantic
@dataclass
class Order:
id: int
items: list[str]
→
class Order(BaseModel):
id: int
items: list[str]
Mostly works. Gotchas:
field(default_factory=...)→Field(default_factory=...).__post_init__→model_validator(mode="after")orfield_validator.dataclass(frozen=True)→ConfigDict(frozen=True).
Pydantic v1 → v2
The big migration. Most fields:
class Config:→model_config = ConfigDict(...).@validator→@field_validator(with@classmethod)..dict()→.model_dump();.json()→.model_dump_json()..parse_obj()→.model_validate().Config.orm_mode = True→ConfigDict(from_attributes=True).BaseSettings→ frompydantic_settingspackage.
There’s a bump-pydantic migration tool that handles most of this.
Hybrid pattern
Common in larger codebases:
- Pydantic at HTTP boundaries (FastAPI request/response).
- attrs / dataclass for internal domain models (no validation overhead per call).
- Convert between them at the boundary.
class OrderIn(BaseModel): # Pydantic — boundary
id: int
items: list[str]
@dataclass
class Order: # internal
id: int
items: list[str]
@app.post("/orders")
async def create_order(order_in: OrderIn):
order = Order(id=order_in.id, items=order_in.items)
await process(order)
Avoids Pydantic overhead on every internal function call while keeping API validation.
Interview angle
- “dataclass vs Pydantic — when each?” — dataclass: internal data, no validation needed, performance matters, zero deps. Pydantic: API boundaries, config, anywhere you need validation + JSON + schema. The role is different: dataclass is “named tuple but a class”; Pydantic is “parse-and-validate framework.”
- “What does
@dataclassactually generate?” —__init__,__repr__,__eq__based on the class’s annotations. With options:__hash__,__lt__(order=True),__slots__(3.10+), immutability (frozen=True). It’s just code generation; no runtime overhead beyond the generated methods. - “Pydantic v2 vs v1?” — v2 is much faster (Rust-backed core), uses
model_config = ConfigDict()instead of innerclass Config,@field_validatorinstead of@validator,.model_dump()instead of.dict(). Migration is mostly mechanical viabump-pydantic. - “Why use attrs over dataclass?” —
evolve()for immutable copies, real validators in__init__, converters for input transformation, slots by default, better for library code. dataclasses is “lightweight, in stdlib”; attrs is “richer feature set, third-party.” - “Does Pydantic v2 coerce strings to ints?” — yes by default.
OrderIn(id="42")works. UseConfigDict(strict=True)orField(strict=True)for strict matching. Coercion is usually right for HTTP (everything starts as a string); strict is right for internal cross-service. - “What’s
model_constructfor?” — skip validation when you’ve already validated upstream. Much faster thanOrder(**data). Useful in hot paths where the data source is trusted. - “Do dataclass type hints get enforced?” — no.
@dataclass class X: a: intacceptsX(a="not an int")without complaint. Type hints are documentation only. Use Pydantic or attrs validators for runtime enforcement.