It's Tuesday afternoon and your Python script just crashed in production. The traceback is short and unfriendly: TypeError: unsupported operand type(s) for *: 'str' and 'int'. Somewhere upstream, a JSON payload delivered "quantity": "3" instead of "quantity": 3, and nothing in your code noticed until the multiplication blew up.
This is the exact problem that dataclasses, TypedDict and Pydantic all try to solve, in very different ways. Picking the wrong one means either writing boilerplate you don't need or trusting data you shouldn't. Let's sort out which tool fits which job.
The core difference: describing data vs. checking data
Before comparing syntax, hold on to one idea: type hints in Python are not enforced at runtime. Writing quantity: int tells your editor and type checker what you intend. It does not stop a string from sneaking in.
A type hint is a promise you make to your tools. Validation is a check you make against the world.
That distinction splits our three options neatly. TypedDict and dataclasses describe the shape of data. Pydantic checks it, and can convert it too. Everything else follows from that.
dataclasses: tidy containers for data you already trust
The standard library's dataclasses module generates __init__, __repr__ and __eq__ for you, so a plain class stops being a wall of boilerplate.
from dataclasses import dataclass, field
@dataclass(frozen=True)
class OrderLine:
sku: str
quantity: int
tags: list[str] = field(default_factory=list)
line = OrderLine(sku="A-100", quantity=3)
print(line) # OrderLine(sku='A-100', quantity=3, tags=[])Notice what happens if you pass bad data: OrderLine(sku="A-100", quantity="3") works without complaint. The dataclass happily stores the string. That's fine when the data comes from your own code, where a type checker like mypy or pyright has already verified the calls.
Dataclasses shine for internal domain objects: things created and passed around inside your application. Use frozen=True when you want immutability, and slots=True (Python 3.10+) when you're creating many instances and care about memory. Use field(default_factory=list) for mutable defaults, never tags: list = [], which shares one list across every instance.
TypedDict: type hints for dictionaries you can't change
Sometimes the data must stay a dictionary: you're passing it to a library, serializing it directly, or working in a codebase full of dict-based code. TypedDict lets a type checker understand the keys without changing the runtime object at all.
from typing import TypedDict
class OrderLineDict(TypedDict):
sku: str
quantity: int
def total(line: OrderLineDict) -> int:
return line["quantity"] * 10
total({"sku": "A-100", "quantity": 3})At runtime this is just a dict. There is no class instance, no validation, no attribute access. A TypedDict costs nothing and adds nothing except help from your type checker and editor autocompletion.
The takeaway: reach for TypedDict when you're annotating existing dict-shaped data, such as a config object or a response you've already validated elsewhere, rather than designing something new.
Pydantic: the border guard for untrusted input
Now back to our crashed script. The "3" arrived from outside your program, and that's where Pydantic earns its place. It parses and validates data against your type hints when the object is created.
from pydantic import BaseModel, Field, ValidationError
class OrderLine(BaseModel):
sku: str
quantity: int = Field(gt=0)
line = OrderLine.model_validate({"sku": "A-100", "quantity": "3"})
print(line.quantity, type(line.quantity)) # 3 <class 'int'>
try:
OrderLine.model_validate({"sku": "A-100", "quantity": "lots"})
except ValidationError as e:
print(e.errors()[0]["msg"])By default Pydantic v2 runs in a lenient "smart" mode: the string "3" becomes the integer 3, while "lots" raises a clear ValidationError that tells you which field failed and why. If you'd rather reject strings outright, Pydantic offers strict mode.
That makes it a natural fit for API request bodies, config files, environment variables, and anything parsed from JSON or CSV. It is also the engine behind FastAPI's request validation. The tradeoff is an extra dependency and some runtime overhead, which rarely matters at the edges of a system and can matter in tight inner loops.
A quick comparison
| Need | dataclass | TypedDict | Pydantic |
|---|---|---|---|
| Runtime validation | No | No | Yes |
Type coercion ("3" → 3) | No | No | Yes (lenient mode) |
| Runtime object | Class instance | Plain dict | Class instance |
| Dependency | Standard library | Standard library | Third-party |
| Best for | Internal objects | Annotating dicts | Untrusted input |
A practical pattern: validate at the edge, trust inside
You don't have to choose just one. A common and clean design uses two layers:
- At the boundary (HTTP handlers, file loaders, queue consumers) parse raw input with Pydantic.
- Inside the core pass lightweight dataclasses or the validated models around, and let your type checker enforce correctness.
def handle_request(payload: dict) -> int:
line = OrderLine.model_validate(payload) # validate once, at the edge
return price_for(line.sku, line.quantity) # trusted from here onThe rule of thumb is simple. Validate once, where data enters; don't re-validate on every function call. Doing so keeps your code fast and your error messages close to the real source of the problem.
Common mistakes to avoid
A few traps come up again and again. Don't assume a dataclass validates anything just because you wrote type hints. Don't use mutable default values without default_factory. Don't sprinkle Pydantic models through hot loops where plain dataclasses would do. And don't forget that TypedDict vanishes at runtime, so isinstance(x, MyTypedDict) will fail.
Also remember that Pydantic's behavior differs between major versions (v1 used .parse_obj, v2 uses .model_validate), so check which one your project pins before copying snippets. This reflects the library as of writing.
Summary
Use a dataclass for objects you create and control, a TypedDict to put types on dictionaries you can't or won't replace, and Pydantic whenever data crosses a trust boundary and you need it checked and converted. Most real projects end up using a mix: strict at the doors, relaxed in the living room.
Next time a stray "3" shows up in your payload, you'll know exactly where to put the guard. Happy coding, and may your tracebacks stay short.


