Pydantic & core / Chapter 3 of 5

Advanced models and JSON Schema

Use Pydantic TypeAdapter, RootModel, generics, recursive models, and ORM attributes. Generate JSON Schema for input and output contracts.

7 topicsUpdated
22 / 44

TypeAdapter, TypedDict, and RootModel#

Validate a list or one type without inventing an unnecessary object wrapper.

TypeAdapter(T) supplies validation, serialization, and JSON Schema for arbitrary supported types. A TypedDict describes dictionary keys and returns a dictionary after validation. RootModel[T] creates a real model whose entire value is T; its JSON output has no extra root key. Use it when you want model methods or validators on a top-level array or scalar.

PythonValidate an import list and a top-level shipment array
from typing import Annotated
from typing_extensions import TypedDict, NotRequired
from pydantic import TypeAdapter, Field, RootModel, ValidationError

class ImportRow(TypedDict):
    sku: str
    quantity: Annotated[int, Field(gt=0)]
    note: NotRequired[str]

adapter = TypeAdapter(list[ImportRow])
rows = adapter.validate_json('[{"sku":"BK-RUST","quantity":"2"}]')
print(rows)
print(adapter.dump_json(rows).decode())
try:
    adapter.validate_python([{'sku': 'BK-RUST', 'quantity': 0}])
except ValidationError as exc:
    print(exc.errors()[0]['loc'], exc.errors()[0]['type'])

class ShipmentIds(RootModel[Annotated[list[str], Field(min_length=1)]]):
    def first(self):
        return self.root[0]

ids = ShipmentIds.model_validate(['DH-42', 'CT-43'])
print(ids.first(), ids.model_dump_json())
try:
    ShipmentIds.model_validate([])
except ValidationError as exc:
    print('empty shipment list:', exc.errors()[0]['type'])
TextExpected output
[{'sku': 'BK-RUST', 'quantity': 2}]
[{"sku":"BK-RUST","quantity":2}]
(0, 'quantity') greater_than
DH-42 ["DH-42","CT-43"]
empty shipment list: too_short
Two different JSON return types

BaseModel.model_dump_json() returns str. TypeAdapter.dump_json() returns bytes, like core’s serializer. Build and reuse adapters outside hot loops. A RootModel does not support extra configuration because it has no named object fields.

NotRequired permits an omitted key, not a null value. Use typing_extensions.TypedDict on Python 3.11/3.12. The adapter returns dictionaries; ShipmentIds returns a model with a validated list in root. Its minimum length makes first() safe on a validated instance.

Official reference: TypeAdapter, TypedDict, and RootModel.

23 / 44

Generic models#

Reuse a response envelope while keeping its contents fully typed.

A TypeVar names a type parameter. Page[Invoice] fills that parameter with a concrete model, so every item is validated as an invoice. In v2, inherit from BaseModel and Generic[T]; the older separate GenericModel base is unnecessary.

PythonA paginated invoice response
from typing import Generic, TypeVar
from pydantic import BaseModel, Field, ValidationError

T = TypeVar('T')
class Page(BaseModel, Generic[T]):
    items: list[T]
    total: int = Field(ge=0)
    next_cursor: str | None = None

class Invoice(BaseModel):
    number: str
    amount_cents: int = Field(gt=0)

page = Page[Invoice].model_validate({
    'items': [{'number': 'INV-7', 'amount_cents': '1990'}], 'total': 1
})
print(type(page.items[0]).__name__, page.model_dump_json())
try:
    Page[Invoice].model_validate({'items': [{'number': 'INV-7', 'amount_cents': -1}], 'total': 1})
except ValidationError as exc:
    print(exc.errors()[0]['loc'], exc.errors()[0]['type'])
TextExpected output
Invoice {"items":[{"number":"INV-7","amount_cents":1990}],"total":1,"next_cursor":null}
('items', 0, 'amount_cents') greater_than
Always supply the intended type parameter

An unparameterized generic follows the TypeVar’s bound, constraints, or default, or uses an Any fallback; it can validate or serialize less precisely than intended. Generic instance revalidation can run validators again, so do not send emails or perform writes in them. For runtime checks, use a concrete subclass rather than relying on parameterized isinstance checks.

Page[Invoice] validates every item as an Invoice; Page[Customer] reuses the envelope for customers. Add a model validator for relationships between fields, such as total >= len(items).

Official reference: Generic models.

24 / 44

Recursive models and forward references#

Describe trees without limiting them to a fixed number of levels.

from __future__ import annotations postpones annotation evaluation, allowing a class to refer to itself or to a class defined later. Pydantic often resolves these automatically. Call model_rebuild() after all referenced types exist when a schema could not be completed earlier. Rebuild the outermost model only after its nested types are ready.

PythonValidate a folder tree and detect a cyclic input
from __future__ import annotations
from pydantic import BaseModel, Field, ValidationError

class Folder(BaseModel):
    name: str
    children: list[Folder] = Field(default_factory=list)

tree = Folder.model_validate({'name': 'papers', 'children': [{'name': '2026'}]})
print(tree.children[0].name)
print(tree.model_dump_json())
cycle = {'name': 'loop'}
cycle['children'] = [cycle]
try:
    Folder.model_validate(cycle)
except ValidationError as exc:
    print(exc.errors()[0]['type'])
TextExpected output
2026
{"name":"papers","children":[{"name":"2026","children":[]}]}
recursion_loop
PythonResolve a type defined later
from __future__ import annotations
from pydantic import BaseModel

class ExportJob(BaseModel):
    destination: StorageTarget

class StorageTarget(BaseModel):
    bucket: str

ExportJob.model_rebuild()
job = ExportJob.model_validate({'destination': {'bucket': 'journal-exports'}})
print(job.destination.bucket)
TextExpected output
journal-exports
A tree is different from an object graph

Self-referential types are supported, but cyclic data is rejected with recursion_loop. Deep or huge untrusted input still needs request-size and resource limits. For recursive aliases without a model, use named aliases such as TypeAliasType; unnamed recursive aliases can fail to resolve.

A Folder may contain more folders, but the input must be finite. A dictionary containing itself raises recursion_loop. A forward reference names a type defined later.

Official reference: Recursive models and forward references.

25 / 44

Pydantic dataclasses#

Add validation to a dataclass-shaped object, then use an adapter to dump it.

pydantic.dataclasses.dataclass validates constructor arguments while preserving dataclass conventions. __post_init__ runs after validation. Dataclasses do not gain model_dump methods; use a TypeAdapter for output and JSON Schema. Standard-library dataclasses can also be validated through an adapter.

PythonA validated parcel dataclass
from dataclasses import field
from pydantic import ConfigDict, Field, TypeAdapter, ValidationError
from pydantic.dataclasses import dataclass

@dataclass(config=ConfigDict(extra='forbid', validate_assignment=True))
class Parcel:
    tracking_id: str
    weight_kg: float = Field(gt=0)
    labels: list[str] = field(default_factory=list)

    def __post_init__(self):
        self.labels.append('heavy' if self.weight_kg >= 10 else 'standard')

parcel = Parcel(tracking_id='DH-42', weight_kg='12.5')
adapter = TypeAdapter(Parcel)
print(parcel.labels, adapter.dump_json(parcel).decode())
try:
    Parcel(tracking_id='DH-42', weight_kg=0)
except ValidationError as exc:
    print(exc.errors()[0]['type'])
TextExpected output
['heavy'] {"tracking_id":"DH-42","weight_kg":12.5,"labels":["heavy"]}
greater_than
Choose by the shape your code needs

Use BaseModel for request/response objects with model methods and Pydantic field behavior. Use dataclasses when the rest of your code expects dataclass objects. Pydantic dataclasses support extra="forbid" and "ignore"; extra="allow" does not have the same fully supported behavior as BaseModel extras.

Post-init and mutation

Parcel.__post_init__ runs after validation and creates its label once. Later weight changes do not recalculate that label; use a property when the value must stay current.

Official reference: Pydantic dataclasses.

26 / 44

Read ORM and other attribute-based objects#

Build a response from attributes, with the same validation rules as dictionary input.

ConfigDict(from_attributes=True) lets model_validate read attributes from a row, ORM entity, or another object. Configure nested response models too when they receive nested objects. This replaces the v1 orm_mode/from_orm pattern.

PythonValidate an attribute-based customer and address
from dataclasses import dataclass
from pydantic import BaseModel, ConfigDict, ValidationError

@dataclass
class AddressRow:
    city: str

@dataclass
class CustomerRow:
    id: int
    name: str
    address: AddressRow

class AddressOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    city: str

class CustomerOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: int
    name: str
    address: AddressOut

row = CustomerRow(id=7, name='Mira', address=AddressRow(city='Dhaka'))
print(CustomerOut.model_validate(row).model_dump_json())
try:
    CustomerOut.model_validate(CustomerRow(id='bad', name='Mira', address=row.address))
except ValidationError as exc:
    print(exc.errors()[0]['loc'], exc.errors()[0]['type'])
TextExpected output
{"id":7,"name":"Mira","address":{"city":"Dhaka"}}
('id',) int_parsing
Attribute access can trigger queries

An ORM relationship or property may execute work when read. Load the required data explicitly before validation, especially with async ORMs where implicit I/O may fail. from_attributes neither opens a session nor creates eager loading. Use a dedicated response model to control exposed attributes.

CustomerOut reads the loaded object’s attributes; AddressOut validates the nested address. The dataclasses stand in for ORM objects, so this example needs no database.

Official reference: Read ORM and other attribute-based objects.

27 / 44

JSON Schema: input contracts and output contracts#

Describe the shape of data for API documentation and other tools.

model_json_schema() and TypeAdapter.json_schema() generate JSON Schema, not a core schema. The validation schema describes accepted input; the serialization schema describes output. Pydantic targets JSON Schema Draft 2020-12. Titles, descriptions, and examples document a rule; arbitrary Python validators cannot generally be translated into that rule.

PythonCompare validation and serialization schemas
import json
from decimal import Decimal
from pydantic import BaseModel, Field, computed_field

class Invoice(BaseModel):
    number: str = Field(description='Public invoice number', examples=['INV-7'])
    amount: Decimal = Field(gt=0)

    @computed_field
    @property
    def label(self) -> str:
        return f'{self.number}: {self.amount}'

incoming = Invoice.model_json_schema(mode='validation')
outgoing = Invoice.model_json_schema(mode='serialization')
print('input properties:', sorted(incoming['properties']))
print('output properties:', sorted(outgoing['properties']))
print('amount output type:', outgoing['properties']['amount']['type'])
print('computed readOnly:', outgoing['properties']['label']['readOnly'])
print(json.dumps(incoming['properties']['number'], indent=2))
TextExpected output
input properties: ['amount', 'number']
output properties: ['amount', 'label', 'number']
amount output type: string
computed readOnly: True
{
  "description": "Public invoice number",
  "examples": [
    "INV-7"
  ],
  "title": "Number",
  "type": "string"
}
Schema customization must describe real behavior

Use Field(json_schema_extra=...), WithJsonSchema, __get_pydantic_json_schema__, or a GenerateJsonSchema subclass when necessary. These do not change validation. If a before/plain validator accepts extra input types, use its json_schema_input_type to document them accurately. Schema generation can fail for unsupported arbitrary classes.

Schema definitions

JSON Schema describes types; it does not execute Python validators or capture every coercion rule. $defs contains reusable definitions and $ref refers to them. Keep runtime validation and tests.

Official reference: JSON Schema: input contracts and output contracts.

28 / 44

Versioned webhooks and nested discriminated unions#

Select the protocol version first, then the event type inside that version.

An outer discriminator can select a nested union that has its own discriminator. Here every version 1 payload has version="1"; its event selects a paid or refunded shape. Version 2 uses a different amount representation. Validate the transport schema, then normalize it into the data your application needs.

PythonA complete webhook parser for two protocol versions
from decimal import Decimal
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError

class WireModel(BaseModel):
    model_config = ConfigDict(extra='forbid')

class PaidV1(WireModel):
    version: Literal['1']
    event: Literal['invoice.paid']
    invoice_id: str
    amount_cents: int = Field(gt=0)

class RefundedV1(WireModel):
    version: Literal['1']
    event: Literal['invoice.refunded']
    invoice_id: str
    refund_cents: int = Field(gt=0)

V1 = Annotated[PaidV1 | RefundedV1, Field(discriminator='event')]

class PaidV2(WireModel):
    version: Literal['2']
    event: Literal['invoice.paid']
    invoice_id: str
    amount: Decimal = Field(gt=0, decimal_places=2)
    currency: Literal['USD', 'BDT']

Webhook = Annotated[V1 | PaidV2, Field(discriminator='version')]
webhook_adapter = TypeAdapter(Webhook)

def normalized_amount(event) -> Decimal:
    if isinstance(event, PaidV1):
        return Decimal(event.amount_cents) / 100
    if isinstance(event, RefundedV1):
        return -Decimal(event.refund_cents) / 100
    return event.amount

for raw in [
    '{"version":"1","event":"invoice.paid","invoice_id":"INV-7","amount_cents":1990}',
    '{"version":"1","event":"invoice.refunded","invoice_id":"INV-7","refund_cents":500}',
    '{"version":"2","event":"invoice.paid","invoice_id":"INV-7","amount":"19.90","currency":"USD"}',
]:
    event = webhook_adapter.validate_json(raw)
    print(type(event).__name__, normalized_amount(event))
    assert webhook_adapter.validate_json(webhook_adapter.dump_json(event)) == event

try:
    webhook_adapter.validate_python({
        'version': '1', 'event': 'invoice.paid',
        'invoice_id': 'INV-7', 'amount_cents': 0
    })
except ValidationError as exc:
    print(exc.errors()[0]['loc'], exc.errors()[0]['type'])
TextExpected output
PaidV1 19.9
RefundedV1 -5
PaidV2 19.90
('1', 'invoice.paid', 'amount_cents') greater_than
First choice: versionSecond choice: eventModel / amount field
"1""invoice.paid"PaidV1 / amount_cents
"1""invoice.refunded"RefundedV1 / refund_cents
"2"Only "invoice.paid" is supported herePaidV2 / amount and currency

The payload stays flat. The outer union reads version; the version-1 branch then reads event. normalized_amount() converts cents to Decimal and makes refunds negative without changing the model. Error locations include the selected branch labels before the failing field.

Webhook verification comes before business actions

Check the provider’s signature against the original raw bytes before using the payload. A valid model does not prove authenticity or prevent duplicate delivery. Store the provider event ID for idempotency. Version 1 here assumes USD minor units; do not generalize division by 100 to every currency.

PythonA callable discriminator for a legacy tag that moved
from typing import Annotated, Literal
from pydantic import BaseModel, Discriminator, Tag, TypeAdapter

class LegacyEvent(BaseModel):
    event_type: Literal['paid']
    invoice_id: str

class CurrentEvent(BaseModel):
    kind: Literal['refund']
    invoice_id: str

def event_tag(value):
    if isinstance(value, dict):
        return value.get('kind', value.get('event_type'))
    return getattr(value, 'kind', getattr(value, 'event_type', None))

Event = Annotated[
    Annotated[LegacyEvent, Tag('paid')] | Annotated[CurrentEvent, Tag('refund')],
    Discriminator(event_tag)
]
adapter = TypeAdapter(Event)
event = adapter.validate_python({'event_type': 'paid', 'invoice_id': 'INV-7'})
print(adapter.dump_json(event).decode())
TextExpected output
{"event_type":"paid","invoice_id":"INV-7"}
Callable discriminators also see model instances

Serialization can call the discriminator with an already validated model. Handle both dictionaries and model objects, as above. Return None if no tag is available so validation can report a missing tag.

Official reference: Versioned webhooks and nested discriminated unions.