Turn a JSON response into Python classes: a dataclass, a TypedDict, or a Pydantic model when you want the data checked at runtime. Field names are converted to snake_case, so userId becomes user_id, and the Pydantic output adds an alias to every renamed field so it still reads the original key.
{
"id": 1,
"name": "Ana",
"orders": [
{ "order_id": 1001, "note": "gift" },
{ "order_id": 1002 }
]
} from dataclasses import dataclass
@dataclass
class User:
id: int
name: str
orders: list["Order"]
@dataclass
class Order:
order_id: int
note: str | None = None Use it when you keep plain dicts and only want mypy to catch typos. It costs nothing at runtime and protects nothing at runtime.
Use it for real objects with attribute access, equality and a readable repr. It still does no validation, because annotations are metadata, not checks.
Use Pydantic at an API boundary. It validates and coerces the data as it arrives, and it is the only one of the three that raises an error when the payload is wrong.
Required fields are emitted before optional ones. Python raises a TypeError at class-definition time when a field without a default follows one with a default, so any other order would fail on import.
The output uses list[str] and
str | None rather than List[str] and
Optional[str]. Built-in generics arrived in Python 3.9 and the pipe union in 3.10, so 3.10 is the floor. On 3.7 to 3.9, add from __future__ import annotations at the top of the file and both forms work for dataclasses and TypedDicts, because annotations are no longer evaluated at runtime. Pydantic evaluates them itself, so on 3.9 it also needs the eval_type_backport package.
Every key is converted to snake_case, and keys that cannot be attribute names are repaired too:
content-type becomes content_type,
and a key that starts with a digit gets a prefix. For Pydantic the tool adds an alias and sets
populate_by_name, so the model still reads the original key off the wire. A dataclass or TypedDict has no alias, so map any renamed key yourself before you pass the data in.
# pip install pydantic
from pydantic import BaseModel, Field
import httpx
class Order(BaseModel):
order_id: int
total: float
note: str | None = None
class User(BaseModel):
model_config = {"populate_by_name": True}
id: int
name: str
content_type: str | None = Field(None, alias="content-type")
orders: list[Order]
res = httpx.get("https://api.example.com/user/1")
user = User.model_validate(res.json()) # raises ValidationError if the shape is wrong
print(user.orders[0].total)
print(user.model_dump_json(by_alias=True)) model_validate is the step that matters. A dataclass accepts a string where you declared an int, and the failure shows up somewhere else later.
from dataclasses import dataclass, asdict
from typing import TypedDict
import json
@dataclass
class Order:
order_id: int
total: float
note: str | None = None
# Nested dataclasses need building by hand: this does not recurse
raw = json.loads('{"order_id": 1, "total": 10.5}')
order = Order(**raw)
print(asdict(order))
# TypedDict keeps the value a plain dict; mypy checks it, nothing else does
class OrderDict(TypedDict, total=False):
order_id: int
total: float
note: str | None
d: OrderDict = {"order_id": 1, "total": 10.5} Pydantic v2 rewrote the validation core in Rust and is roughly five to fifty times faster than v1 depending on the workload, which removed most of the performance argument against validating at the boundary. The API changed too: parse_obj became model_validate, .dict() became .model_dump(), and Config became model_config. Code and answers written for v1 will not run on v2.
This tool follows the specifications below. Read them when you need the exact normative wording, for example to settle an edge case between two implementations.
Use TypedDict when you keep plain dicts and only want the type checker's help. Use a dataclass for real objects with attribute access. Use Pydantic when the data should be validated at runtime, which is the right choice for anything arriving over a network.
The tool converts every key to snake_case, so content-type becomes content_type and userId becomes user_id, and a key that starts with a digit, such as 2fa, gets an f_ prefix. For Pydantic it adds an alias so the original key still maps. For a dataclass or TypedDict, map the renamed keys yourself.
The modern one: list[str] and str | None rather than List[str] and Optional[str]. That needs Python 3.10 or later. On 3.7 to 3.9, add from __future__ import annotations at the top of the file; Pydantic on 3.9 also needs the eval_type_backport package.
Required fields come first, then the ones with defaults. Python raises a TypeError at class-definition time when a field without a default follows one with a default, so any other order would fail on import.
No. Annotations are metadata, so passing a str where you declared int works until some later code breaks on it. If you need enforcement, use Pydantic, or run mypy in CI to catch it statically.
The same sample becomes a TypeScript interface or a Go struct. For a contract rather than a class, the JSON Schema generator produces one you can validate against in any language.