A dataclass gives a JSON object a name, a set of typed fields and free __repr__ and __eq__ methods. What it does not give you is parsing: json.loads returns plain dicts, and the type hints on a dataclass are never checked at runtime. This guide covers the hints worth writing, how to handle optional and nested data, and the point where Pydantic earns its dependency. The samples ran on Python 3.12 and Pydantic 2.14.
JSON has strings, numbers, booleans, arrays, objects and null, which map to str, int or float, bool, list[T], a nested class or a dict, and None. Timestamps arrive as strings, so a datetime field only works if you convert it yourself. Use built-in generics such as list[str] and the X | None syntax on Python 3.10 and newer; on older versions, import Optional and List from typing.
The JSON to Python converter follows the same mapping: str, int, float, bool, list[T], datetime for ISO-8601 timestamps with a time part, and Any for a key that is always null. It emits plain @dataclass classes without defaults, Optional or from_dict, because a single sample cannot show which keys are optional. Those parts are yours to add.
str | None says a value may be None; it does not make the field optional in the constructor. For that, give it a default: = None. Dataclasses require fields with defaults to come after fields without them, and a mutable default such as [] is rejected, so use field(default_factory=list). A key that may be missing and a key that may be null then both end up as None or an empty list, which is usually what you want.
Passing **data to the constructor is tempting, but it only works for flat objects with exactly the right keys. A nested object stays a dict, a timestamp stays a string, and an extra key raises TypeError. A from_dict classmethod makes each conversion explicit and chooses what to do with unknown keys, which here is to ignore them:
from __future__ import annotations
import json
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
@dataclass
class Address:
city: str
zip: str | None = None
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Address:
return cls(city=data["city"], zip=data.get("zip"))
@dataclass
class User:
id: int
name: str
created_at: datetime
address: Address
tags: list[str] = field(default_factory=list)
nickname: str | None = None
@classmethod
def from_dict(cls, data: dict[str, Any]) -> User:
return cls(
id=data["id"],
name=data["name"],
created_at=datetime.fromisoformat(data["created_at"]),
address=Address.from_dict(data["address"]),
tags=data.get("tags", []),
nickname=data.get("nickname"),
)
raw = """{"id": 7, "name": "Ada", "created_at": "2024-05-01T10:30:00Z",
"address": {"city": "Oslo"}, "nickname": null, "plan": "pro"}"""
data = json.loads(raw)
print(User.from_dict(data))
# Unpacking skips the conversion: no error, but the wrong types
naive = User(**{k: v for k, v in data.items() if k != "plan"})
print(type(naive.address).__name__, type(naive.created_at).__name__)
The first line printed is the User with a timezone-aware datetime and an Address instance. The second line is dict str, which is what User(**data) produced: the dataclass accepted the wrong types without complaint. Note that fromisoformat reads a trailing Z only on Python 3.11 and newer.
The converter writes one file per class and adds sibling imports such as from address import Address. They work when the files share a directory on the import path. Inside a package, change them to relative imports like from .address import Address.
dataclasses.asdict(user) converts nested dataclasses recursively, but json.dumps then fails on the datetime with "Object of type datetime is not JSON serializable". Pass default=lambda value: value.isoformat() to write ISO-8601 strings. The shorter default=str also works, but it writes 2024-05-01 10:30:00, with a space where ISO-8601 has a T.
Hand-written from_dict methods grow with every field, and none of them validate. Pydantic replaces them with a model that parses, validates and converts nested data in one call. The same model needs no conversion code:
from datetime import datetime
from pydantic import BaseModel, ValidationError
class Address(BaseModel):
city: str
zip: str | None = None
class User(BaseModel):
id: int
name: str
created_at: datetime
address: Address
tags: list[str] = []
nickname: str | None = None
raw = """{"id": "7", "name": "Ada", "created_at": "2024-05-01T10:30:00Z",
"address": {"city": "Oslo"}, "nickname": null, "plan": "pro"}"""
print(User.model_validate_json(raw))
try:
User.model_validate_json('{"id": "abc", "name": "Ada"}')
except ValidationError as err:
print(err.error_count(), "errors")
The first call accepts "7" for id, builds the Address, parses the timestamp and ignores plan. The second reports 3 errors at once: a bad id, a missing created_at and a missing address. A model also writes JSON back out with model_dump_json(), and its fields can generate a JSON Schema.
Choose a plain dataclass for data you produce yourself or already trust, in code that should stay free of dependencies. Choose Pydantic for input from outside your process, such as API responses, queues and user files, where a wrong type should fail at the boundary instead of three calls later. Models from the converter can be a starting point for either: replace @dataclass with BaseModel as the base class, then add the optional fields.