Backend / Python core / Stdlib / 08_dataclasses_vs_pydantic_vs_attrs.md

dataclasses vs Pydantic v2 vs attrs

Updated 7 interview angles 6 min read source
On this page12
  1. The TL;DR
  2. dataclasses
  3. Pydantic v
  4. attrs
  5. Performance comparison
  6. Coercion vs strict typing
  7. When to use dataclass over Pydantic
  8. When to use Pydantic over dataclass
  9. When to use attrs over both
  10. Migration patterns
  11. Hybrid pattern
  12. Interview angle

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.

python
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.asdict exists, 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.

python
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 clear ValidationError.
  • .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_field for 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.

python
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 fieldsattrs.field(init=False), alias="x".
  • Slotted by default with @attrs.define.
  • attrs.frozen decorator 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:

python
OrderIn(id="42").id    # 42 (int)
OrderIn(id="abc").id   # ValidationError

If you want strict matching (no coercion):

python
from pydantic import BaseModel, ConfigDict

class StrictOrder(BaseModel):
    model_config = ConfigDict(strict=True)
    id: int

# ValidationError — strict refuses string-to-int
StrictOrder(id="42")

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

python
@dataclass
class Order:
    id: int
    items: list[str]

python
class Order(BaseModel):
    id: int
    items: list[str]

Mostly works. Gotchas:

  • field(default_factory=...)Field(default_factory=...).
  • __post_init__model_validator(mode="after") or field_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 = TrueConfigDict(from_attributes=True).
  • BaseSettings → from pydantic_settings package.

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.
python
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 7

  • “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 @dataclass actually 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 inner class Config, @field_validator instead of @validator, .model_dump() instead of .dict(). Migration is mostly mechanical via bump-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. Use ConfigDict(strict=True) or Field(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_construct for?” — skip validation when you’ve already validated upstream. Much faster than Order(**data). Useful in hot paths where the data source is trusted.
  • “Do dataclass type hints get enforced?” — no. @dataclass class X: a: int accepts X(a="not an int") without complaint. Type hints are documentation only. Use Pydantic or attrs validators for runtime enforcement.