Advanced models and JSON Schema
Use Pydantic TypeAdapter, RootModel, generics, recursive models, and ORM attributes. Generate JSON Schema for input and output contracts.
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.
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'])[{'sku': 'BK-RUST', 'quantity': 2}]
[{"sku":"BK-RUST","quantity":2}]
(0, 'quantity') greater_than
DH-42 ["DH-42","CT-43"]
empty shipment list: too_shortBaseModel.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.
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.
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'])Invoice {"items":[{"number":"INV-7","amount_cents":1990}],"total":1,"next_cursor":null}
('items', 0, 'amount_cents') greater_thanAn 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.
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.
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'])2026
{"name":"papers","children":[{"name":"2026","children":[]}]}
recursion_loopfrom __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)journal-exportsSelf-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.
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.
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'])['heavy'] {"tracking_id":"DH-42","weight_kg":12.5,"labels":["heavy"]}
greater_thanUse 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.
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.
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.
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']){"id":7,"name":"Mira","address":{"city":"Dhaka"}}
('id',) int_parsingAn 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.
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.
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))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"
}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.
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.
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.
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'])PaidV1 19.9
RefundedV1 -5
PaidV2 19.90
('1', 'invoice.paid', 'amount_cents') greater_than| First choice: version | Second choice: event | Model / amount field |
|---|---|---|
"1" | "invoice.paid" | PaidV1 / amount_cents |
"1" | "invoice.refunded" | RefundedV1 / refund_cents |
"2" | Only "invoice.paid" is supported here | PaidV2 / 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.
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.
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()){"event_type":"paid","invoice_id":"INV-7"}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.
Help improve CodeRef
Allow Google Analytics to measure page visits and use of the guides? It uses cookies after you accept. Your choice won’t affect access to the site. Privacy notice