Four tools, four genuine niches. The reason "which one should I use" is the most-asked Python typing question in 2026 is that the answer actually depends on context. Pydantic is the right choice for HTTP boundaries and untrusted input. Dataclasses are the right choice for internal value objects where the standard library is preferred. Attrs is the right choice for projects that need converters, slots-by-default, or fine-grained class machinery. TypedDict is the right choice for typing dict shapes that pass through without conversion. This guide walks through each tool with runnable examples, lays out a decision tree, and shows the layering pattern most production codebases settle on: Pydantic at the boundary, dataclasses internally, TypedDict where data stays a dict. Part of our Modern Python 2026 guide.

Key Takeaways

  • Pydantic v2: trust boundaries with runtime validation. Rust core for serialization speed. Use at HTTP routes, JSON parsing, config files.
  • dataclasses: standard library value objects. Fastest for in-process data. Use for domain models, internal records, anything you trust.
  • attrs: dataclass's older sibling with extras (slots-by-default, converters, composable validators). Worth it for libraries and complex domain models.
  • TypedDict: dict shapes typed for static analysis only. Use when data arrives as a dict and stays as a dict (JSON payloads, kwargs).
  • Common layering: Pydantic at the boundary, dataclasses internally, TypedDict where data passes through without conversion.
Decision tree: which structured-data tool fits Pick the right structured-data tool PYDANTIC · DATACLASS · ATTRS · TYPEDDICT START Where does the data come from? EXTERNAL INTERNAL UNTRUSTED INPUT Need runtime validation and helpful error messages? IN-PROCESS DATA Need converters, validators, or slots by default? YES NO (stays dict) YES NO Pydantic HTTP boundaries JSON APIs YAML config user input forms Rust core for speed TypedDict JSON dict shapes kwargs forwarding config dicts stays a dict static analysis only attrs complex domain models slots by default converters + validators libraries richer than dataclass @dataclass domain objects internal records stdlib only fastest PEP 557 standard LAYERING IN A REAL APP A typical FastAPI service uses all four: · Pydantic models for request and response shapes (validated at the route boundary); · dataclasses for domain objects used inside business logic; · TypedDict for kwargs and intermediate dict shapes that pass through; · attrs in places where you need converters or slots-by-default (less common in app code, common in libraries).
The decision tree, then the layering pattern. Most production codebases use multiple tools, each at its right layer.

The Four Tools, One Sentence Each

  • Pydantic v2: Runtime validation with a Rust core. Use at trust boundaries.
  • dataclasses: Standard library value objects (PEP 557). Use for trusted in-process data.
  • attrs: Predecessor to dataclasses with more knobs (slots-by-default, converters, validators).
  • TypedDict: Static-only typing for dict shapes. Use when the data is already a dict.
The rest of this entry expands each one with examples, then shows the decision tree and benchmarks.

Pydantic v2: Validation at the Boundary

Pydantic became the canonical "runtime validation" choice for Python because FastAPI uses it for request and response models. Pydantic v2 (2023) rewrote the core in Rust, making serialization fast even when validation overhead is real.
from pydantic import BaseModel, EmailStr, ValidationError

class CreateUser(BaseModel):
    email: EmailStr
    name: str
    age: int

# Coerces compatible types automatically
user = CreateUser(email="alice@example.com", name="Alice", age="30")
print(user)
# CreateUser(email='alice@example.com', name='Alice', age=30)

# Rejects bad input with structured errors
try:
    CreateUser(email="not-an-email", name="Bob", age="not-a-number")
except ValidationError as e:
    print(e.json(indent=2))
# [{"type": "value_error", "loc": ["email"], ...}]
What Pydantic gives you:
  • Runtime validation on every construction, with helpful error messages.
  • Type coercion (e.g., "30" becomes 30; configurable strictness).
  • JSON parsing and serialization via model_validate_json() and model_dump_json().
  • Schema generation for OpenAPI / JSON Schema.
  • Constrained types (conint(ge=0), constr(min_length=1), EmailStr, etc.).
What Pydantic costs you:
  • 2-3x instantiation overhead compared to bare dataclasses on tight loops.
  • External dependency (not in standard library).
  • Learning curve for advanced features (validators, field aliases, model config).
When Pydantic is the right answer: HTTP request bodies, JSON from external APIs, YAML config files, form input from users, anything you can't trust.

dataclasses: The Standard-Library Workhorse

Covered in depth in the @dataclass deep dive. The headline for this comparison: dataclasses are the fastest of the four tools because they skip validation and serialization machinery entirely.
from dataclasses import dataclass, field

@dataclass(frozen=True, slots=True)
class User:
    id: int
    email: str
    name: str
    tags: list[str] = field(default_factory=list)
What dataclasses give you:
  • Auto-generated __init__, __repr__, __eq__.
  • Opt-ins for frozen=True, slots=True, order=True, kw_only=True.
  • Zero runtime cost beyond standard Python class machinery.
  • Standard library: no external dependency.
What dataclasses don't give you:
  • No runtime validation. User(id="oops", ...) constructs successfully, breaks later.
  • No JSON serialization out of the box (use asdict() + json.dumps).
  • No converters (writing self.x = int(value) in __post_init__ works, but it's manual).
When dataclasses are the right answer: internal domain objects, database rows you trust, in-process records, anywhere standard-library-only is preferred.

attrs: The Older Sibling with Extras

attrs predates dataclasses and inspired their design. After dataclasses landed in Python 3.7, attrs lost some mindshare but kept a niche by offering features dataclasses still don't have.
import attrs

@attrs.define
class User:
    id: int = attrs.field(converter=int)
    email: str = attrs.field(validator=attrs.validators.matches_re(r".+@.+"))
    name: str
    tags: list[str] = attrs.field(factory=list)

# Converter coerces input
user = User(id="42", email="a@b.com", name="Alice")
print(user.id)   # 42 (converted to int)

# Validator catches bad input at construction time
try:
    User(id=1, email="not-an-email", name="Bob")
except ValueError as e:
    print(e)
What attrs offers beyond dataclasses:
  • Slots by default with @attrs.define (memory wins without remembering to opt in).
  • Composable validators: attrs.validators.and_, attrs.validators.in_, custom predicates.
  • Converters: run a function at init time to coerce or transform values.
  • Slotted + frozen + converted in one decorator: ergonomic for libraries.
  • cattrs companion for fast serialization (faster than Pydantic for many shapes).
What attrs costs:
  • External dependency.
  • Slightly different API from dataclasses (you have to learn both if you mix).
  • Smaller community share than it had pre-dataclasses, though still actively maintained.
When attrs is the right answer: libraries that need converters and validators, complex domain models, projects that adopted attrs before dataclasses existed and stayed.

TypedDict: Typed Dicts for Static Analysis

Covered briefly in the type hints guide. The full picture here:
from typing import TypedDict, NotRequired

class UserPayload(TypedDict):
    id: int
    email: str
    name: str
    bio: NotRequired[str]    # optional key

def handle_user(payload: UserPayload) -> str:
    return f"{payload['name']} <{payload['email']}>"

handle_user({"id": 1, "email": "a@b.com", "name": "Alice"})    # OK
handle_user({"id": 1, "email": "a@b.com"})                      # mypy: name missing
What TypedDict gives you:
  • Static type checking of dict accesses and constructions.
  • NotRequired / Required (PEP 655) for mixed optional keys.
  • Zero runtime cost; it's a typing hint, not a real class.
  • Drop-in for JSON: data loaded from json.loads() is already a dict.
What TypedDict doesn't give you:
  • No runtime validation. The dict could have extra keys, missing keys, or wrong-typed values; only mypy or Pyright catches it.
  • No methods, no defaults, no __repr__.
  • Read-only structural typing only.
When TypedDict is the right answer: data that arrives as a dict and stays as a dict (JSON payloads forwarded through your app, kwargs forwarded between functions, config loaded from YAML).

Performance: What the Benchmarks Actually Show

Benchmarks from public sources (Pydantic v2's release notes, ryanlstevens' performance comparison, the Pyrefly team's profiles) put the four tools roughly in this order for instantiation speed on a 5-field model:
ToolRelative speed (instantiation)Relative speed (serialization to dict)Memory per instance
dataclass (no slots)1.0x (fastest)mediumbaseline
dataclass (slots=True)~1.0xmedium~50% baseline
attrs (define, slots default)~1.2xfast (cattrs)~50% baseline
Pydantic v2 BaseModel~2.5x (validates each field)fast (Rust core)~1.5x baseline
TypedDict (it's a dict)~1.0x (dict construction)identity (already a dict)dict overhead
For 99% of applications, these differences are invisible because the cost of network or disk I/O dwarfs object construction. For hot loops constructing millions of objects per second, the differences matter. The honest rule: profile before optimizing, but if you're CPU-bound and constructing many objects, dataclasses or attrs (with slots) win.

Cattrs: The attrs Companion for Serialization

One reason attrs holds its niche against Pydantic: pairing attrs with the cattrs library gives you fast serialization with composable converters, without paying Pydantic's per-field validation cost on every construction.
import attrs
import cattrs

@attrs.define
class User:
    id: int
    email: str
    name: str

# Convert dict to User (structuring)
data = {"id": 42, "email": "a@b.com", "name": "Alice"}
user = cattrs.structure(data, User)

# Convert User back to dict (unstructuring)
back = cattrs.unstructure(user)
print(back)   # {'id': 42, 'email': 'a@b.com', 'name': 'Alice'}
For workloads dominated by serialization (logging structured data, writing records to a queue, building API responses from internal models), attrs + cattrs is often faster than Pydantic v2 because there's no per-field validation overhead and the cattrs hooks can be tuned per-type. The trade-off: cattrs gives you serialization but not the validation, schema generation, or OpenAPI integration that Pydantic ships out of the box. Pick attrs + cattrs when you control the data path and just need fast in-out; pick Pydantic when validation and schemas earn their place.

Pydantic Advanced: Discriminated Unions

One area where Pydantic genuinely outclasses the alternatives: typed routing of inputs through discriminated unions. When an incoming JSON object's shape depends on a discriminator field, Pydantic parses each variant correctly with a single model.
from typing import Literal, Union
from pydantic import BaseModel, Field

class EmailNotification(BaseModel):
    kind: Literal["email"]
    to: str
    subject: str

class SMSNotification(BaseModel):
    kind: Literal["sms"]
    phone: str
    body: str

class Envelope(BaseModel):
    payload: Union[EmailNotification, SMSNotification] = Field(discriminator="kind")

# Pydantic routes by the kind field, giving you the right type
env = Envelope.model_validate({"payload": {"kind": "email", "to": "a@b.com", "subject": "Hi"}})
print(type(env.payload).__name__)   # EmailNotification
The discriminator field tells Pydantic which Literal value distinguishes the variants. Error messages are precise (Pydantic points to which variant failed and why), and type checkers infer the narrowed type after a runtime kind check. dataclass and attrs can model the same shape, but you'd write the routing logic by hand. For polymorphic JSON shapes, Pydantic earns its overhead.

The Layering Pattern

Most production codebases use multiple tools, each at the layer it fits best. Concrete example for a FastAPI service:
# Boundary layer: Pydantic models validate incoming requests
from pydantic import BaseModel, EmailStr

class CreateUserRequest(BaseModel):
    email: EmailStr
    name: str
    age: int

class UserResponse(BaseModel):
    id: int
    email: str
    name: str

# Domain layer: dataclass for internal use
from dataclasses import dataclass

@dataclass(frozen=True, slots=True)
class User:
    id: int
    email: str
    name: str
    age: int

# Intermediate dict shape passed to a logger
from typing import TypedDict

class AuditEntry(TypedDict):
    event: str
    user_id: int
    timestamp: float

# FastAPI route ties them together
@app.post("/users", response_model=UserResponse)
async def create_user(payload: CreateUserRequest) -> UserResponse:
    # Pydantic validated the payload
    user = User(id=next_id(), **payload.model_dump())
    log_audit(AuditEntry(event="user.created", user_id=user.id, timestamp=time.time()))
    return UserResponse(id=user.id, email=user.email, name=user.name)
Each tool is used at the layer it fits: Pydantic at the boundary, dataclass for the domain object, TypedDict for the intermediate audit shape, response_model converts back to Pydantic for serialization.

Migration Patterns

Pydantic v1 to v2

The biggest practical migration for many projects. Pydantic v2 dropped Python 3.7 support and renamed several methods (parse_obj became model_validate, dict() became model_dump()). The official bump-pydantic tool handles most of the rewrite automatically. The Rust core means v2 is faster, but some custom validators need rewriting.

dataclass to Pydantic

Often the right move when a dataclass crosses a trust boundary (the data sometimes arrives from outside). The conversion is mechanical: replace @dataclass with BaseModel and adjust attribute access if needed.

attrs to dataclasses

For projects that adopted attrs pre-Python-3.7 and don't need converters, the move to dataclasses simplifies dependencies. The migration is mechanical but loses converters (you have to write __post_init__ instead). Skip the migration if you actively use attrs features.

Common Mistakes

1. Using Pydantic for in-process objects

Validating every field on every construction when the data is already trusted is wasted CPU. Use a dataclass for objects that flow only inside your code. Save Pydantic for boundaries.

2. Skipping validation at boundaries

The reverse mistake. If JSON arrives from an HTTP request, validate it. Skipping validation because "we trust the frontend" produces subtle bugs and security holes. Pydantic earns its overhead at the boundary.

3. Mixing styles inside one layer

If your routes mix Pydantic and TypedDict, your team has to track which is which. Standardize per layer: pick one tool for the boundary and stick with it. Mixing across layers is fine; mixing within one is friction.

4. Forgetting model_dump() in Pydantic v2

Migrating from v1, some_model.dict() raises a deprecation warning in v2. Use some_model.model_dump(). Same with parse_obj to model_validate.

5. Using TypedDict where a dataclass would be clearer

TypedDict is structural. If you'd benefit from __repr__, defaults, methods, or any class behavior, prefer a dataclass. TypedDict is for data that genuinely stays a dict because it gets serialized again later or passed as **kwargs.

Frequently Asked Questions

When should I use Pydantic instead of dataclasses in Python?

Use Pydantic when the data comes from outside your process and you can't trust its shape or types: HTTP request bodies, JSON from external APIs, YAML config files, user-uploaded forms. Pydantic validates the input, coerces it to the right types, and produces clear error messages when validation fails. Use a dataclass when the data is already in-process and you trust its construction: rows from a database query you control, internal API responses, results of in-process computation. The line is the trust boundary: external data needs Pydantic; internal data only needs a dataclass.

Is Pydantic v2 slower than dataclasses?

Pydantic v2 has a Rust core that makes serialization genuinely fast (often faster than hand-rolled Python code), but instantiation still carries 2-3x overhead compared to bare dataclasses because Pydantic validates every field on every construction. In I/O-bound applications (web servers, ETL pipelines) the overhead is invisible because the network or disk dominates. In CPU-tight loops constructing millions of objects per second, the dataclass advantage matters. The honest rule: choose Pydantic when validation pays for itself; choose dataclass when you've benchmarked and confirmed the overhead hurts.

What is the difference between attrs and dataclasses?

attrs is the predecessor to dataclasses and inspired most of its design. The remaining differences in 2026: attrs supports slots-by-default (memory wins), has a richer validator and converter API (run custom logic on field assignment), supports __attrs_post_init__ hooks that work cleanly with frozen instances, and has a wider plugin ecosystem (attrs.field accepts factory, validator, converter, repr, eq, order, hash, init, metadata). dataclass added slots=True in Python 3.10 to close part of the gap. attrs still wins when you need composable validators, automatic converters (str-to-int casting at init time), or the slotted-by-default ergonomics. For most simple value objects, dataclasses are now sufficient.

When should I use TypedDict instead of a dataclass?

Use TypedDict when the data is already a dict and stays a dict: JSON loaded from an API, kwargs passed between functions, config dicts loaded from YAML. The shape is dict-typed but the keys are known. TypedDict gives the type checker enough information to validate accesses without requiring you to convert to a class. Use dataclass when you'd benefit from methods, defaults, __repr__, or any other class behavior. TypedDict is read-only structural typing; dataclass is a real class. They're complementary, not competing: parse a TypedDict-shaped JSON into a dataclass for downstream code.

Can I mix Pydantic, dataclasses, and TypedDict in the same Python project?

Yes, and the common pattern is exactly that. The standard layering: Pydantic models at the API boundary (FastAPI routes, request bodies, response shapes); dataclasses for internal domain objects passed between functions and modules; TypedDict for typing dicts that pass through the system without conversion. The conversion points are clear: Pydantic.model_dump() to get a dict, then pass to internal functions; dataclass.asdict() to serialize at the response boundary. Mixing is fine as long as the layering is intentional. What you should avoid is unprincipled mixing inside a single layer (some routes using Pydantic, others using TypedDict).

The Bottom Line: Pick by Layer, Not by Preference

Four tools, four niches. Pydantic for the boundary where you can't trust the data. Dataclasses for the in-process domain where you can. attrs for libraries and complex models that need converters or slots-by-default. TypedDict for typed dict shapes that pass through your system. The right answer per project is usually multiple tools layered by responsibility. Up next: what's new in Python 3.14. Or browse the full Modern Python 2026 guide.

Learn the Layering Through Practice

CodeGym's Python track puts the four structured-data tools into 800+ hands-on tasks across 62 levels: Pydantic validation at HTTP routes, dataclasses for domain models, attrs for libraries, TypedDict for JSON shapes. AI validators catch the layering mistakes (Pydantic in tight loops, TypedDict where a class would be clearer); AI mentors explain which tool fits a specific task. First level free; full plan on the pricing page.