backend / python core / stdlib / 08_dataclasses_vs_pydantic_vs_attrs.md

dataclasses vs Pydantic v2 vs attrs

7 interview angles 6 min read source

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

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.

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:

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") 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.
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 @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.