Turning JSON into Python dataclasses

Published October 17, 2026

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.

Type hints for JSON values

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.

Optional fields and defaults

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.

Nested objects and from_dict

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.

Writing JSON back out

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.

When to prefer Pydantic

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.

Related guides