Engineering Notes
Home/Languages/Python/Dataclasses, Pydantic and Data Validation
Languages · Python

Dataclasses, Pydantic and Data Validation

Model data cleanly in Python: dataclasses for internal containers with invariants, Pydantic for parsing and validating untrusted input, and how both use type hints, then build a mini validator yourself.

💻 Languages● Intermediate⏱ 3 min read🛠️ 2 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • Dataclass features: frozen, ordering, default_factory, __post_init__
  • Pydantic models, constraints and validators
  • Choosing between dataclasses, Pydantic and TypedDict
  • How libraries use type hints at runtime

1Dataclasses

@dataclass generates __init__, __repr__ and __eq__ (and optionally ordering, hashing and immutability) from annotated fields:

python
from dataclasses import dataclass, field
from typing import List

@dataclass(frozen=True, order=True)
class Money:
    amount: int            # cents
    currency: str = "EUR"

@dataclass
class Order:
    id: int
    lines: List[str] = field(default_factory=list)   # never a mutable default directly
    def __post_init__(self):
        if self.id <= 0:
            raise ValueError("id must be positive")
  • frozen=True makes instances immutable (and hashable); order=True adds comparisons by field order.
  • slots=True (3.10+) and kw_only=True (3.10+) save memory and force keyword arguments.
  • dataclasses.asdict, replace(obj, amount=5) (a copy with changes).
  • Dataclasses don't validate types at runtime: Money(amount="ten") is accepted unless you check in __post_init__.

2Pydantic

Pydantic models parse and validate untrusted data at runtime from type hints:

python
from pydantic import BaseModel, Field, EmailStr, field_validator

class CreateUser(BaseModel):
    email: EmailStr
    age: int = Field(ge=13, le=120)
    tags: list[str] = []

    @field_validator("tags")
    @classmethod
    def lowercase(cls, v): return [t.lower() for t in v]

user = CreateUser.model_validate({"email": "ana@x.io", "age": "30"})   # "30" coerced to 30
CreateUser.model_validate({"email": "bad", "age": 5})                  # ValidationError listing both fields
user.model_dump_json()                                                  # serialization

Pydantic v2 has a Rust core (fast), strict and lax modes, settings management (pydantic-settings reads env vars), and JSON Schema generation, which FastAPI uses for request validation and OpenAPI docs.

3When to Use What

NeedChoose
Internal data containers, no untrusted input@dataclass (or NamedTuple for tiny records)
Validating external input: API bodies, config, messagesPydantic models
Typed dicts for JSON you don't controlTypedDict (static checking only)
Domain objects with behaviour and invariantsClasses or dataclasses with validation in constructors

Validate at the boundary (HTTP handler, message consumer, config loader), then pass well-typed objects inward.

4Type Hints Drive Everything

Both tools read annotations. typing.get_type_hints(cls) resolves them; typing.get_origin / get_args decompose generics like List[int] or Optional[str]. That's how libraries know to coerce "30" into an int or to validate each list element.

⚠️ Common Pitfalls

  1. Mutable defaults in dataclass fields: use field(default_factory=list).
  2. Assuming dataclasses validate types.
  3. Validating deep inside business logic instead of at the boundary.
  4. Lax coercion surprises ("1" → True for bools); use strict mode where it matters.
  5. Pydantic v1 vs v2 API differences (dict() → model_dump(), parse_obj → model_validate).

📋 Quick Reference

text
@dataclass: __init__/__repr__/__eq__ · frozen · order · field(default_factory=…) · __post_init__ for checks
Pydantic: parse + validate + coerce from type hints · Field constraints · validators · model_dump(_json)
dataclass internally, Pydantic at the boundary · TypedDict for static shapes · get_type_hints / get_origin / get_args

🧠 Key takeaways

  • Dataclasses generate boilerplate; use default_factory and __post_init__ for invariants.
  • Pydantic parses, coerces and validates external data from type hints.
  • Validate at the boundary, pass typed objects inward.
  • get_type_hints, get_origin and get_args power runtime type handling.

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1Dataclass vs Pydantic model?core▾

Dataclasses generate __init__/__repr__/__eq__ for internal data without runtime validation. Pydantic models parse, coerce and validate untrusted data with detailed errors, serialization and JSON Schema. Use Pydantic at boundaries, dataclasses internally.

Follow-ups interviewers ask
  • Pydantic dataclasses? pydantic.dataclasses.dataclass adds validation to dataclass syntax.
Q2How do you enforce invariants in a dataclass?core▾

In __post_init__, raise on invalid values; make it frozen=True so invariants can't be broken later; use replace() for modified copies.

Follow-ups interviewers ask
  • Computed fields? field(init=False) set in __post_init__.
Q3What changed in Pydantic v2?core▾

A Rust core (much faster), model_validate/model_dump instead of parse_obj/dict, field_validator/model_validator decorators, strict mode, and pydantic-settings split out.

Follow-ups interviewers ask
  • Migration tool? bump-pydantic.
Q4How does Pydantic know how to validate a field?senior▾

It reads type hints (resolving forward references), builds a validation schema per field (with constraints from Field and Annotated metadata), and generates efficient validators in pydantic-core.

Follow-ups interviewers ask
  • Annotated? Annotated[int, Field(gt=0)] attaches constraints to types.
Q5When would you use TypedDict?core▾

To describe the shape of dicts (e.g. JSON you pass around) for static type checkers without runtime cost or conversion; it doesn't validate at runtime.

Follow-ups interviewers ask
  • NotRequired? Marks optional keys (3.11+ or typing_extensions).

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.

✅ Check yourself

Answer the questions — feedback is instant.

1. Does @dataclass validate field types at runtime?
Add checks or use Pydantic.
2. How do you give a dataclass field an empty list default?
Avoid shared mutable default.
3. Where should external input be validated?
Boundary validation.
4. What does frozen=True provide?
Immutable instances.