Models, fields, and types
Define Pydantic models with typed fields, defaults, aliases, nested data, and discriminated unions. Handle strict input and partial updates.
Start here: Pydantic and its core#
Turn incoming data into checked Python values, then choose what leaves your application.
Python does not enforce type hints at runtime. Pydantic uses them to check input, convert supported values, and report failures through ValidationError.
Annotated, BaseModel, and TypeAdapter: which one do I use?
Annotated attaches reusable constraints to a type. For example, Annotated[int, Field(gt=0)] describes a positive integer. A model or adapter applies those constraints.
BaseModel defines named fields, defaults, configuration, and model-level rules. Calling the class or model_validate() returns a validated instance.
TypeAdapter validates an existing type without a wrapper model. Use it for a scalar, list, union, TypedDict, or dataclass. The result has the type being validated.
| Word | Meaning in this guide |
|---|---|
| Model / field | A class describing a record / one named value in that record. |
| Validation | Check input and produce the declared Python value, or report why it failed. |
| Coercion | An allowed conversion, such as the string "3" becoming the integer 3. |
| Serialization / dump | Convert checked values into an output dictionary or JSON representation. |
| Schema | A description of data rules. Core schemas tell the engine what to check; JSON Schema documents an external contract. |
| Boundary / payload | A place where data enters or leaves your application / the data being sent. |
Python input means an object already in memory, such as a dictionary, list, or date. JSON input means text or bytes that must be parsed, such as '{"quantity":3,"note":null}'. JSON null becomes Python None; JSON true and false become True and False. The choice of input method can affect which values strict validation accepts.
| Tool | Use it for | What you get |
|---|---|---|
Annotated[T, rules] | Add or reuse rules on a field/item type | A type description; a model or adapter runs its checks |
BaseModel | Named records such as API requests and responses | A validated object, methods, schema, and model tools |
TypeAdapter | Lists, unions, TypedDicts, dataclasses, or one reusable type | Validation, dumping, and JSON Schema without a wrapper model |
Direct pydantic-core | Schema engines and integrations that already build schema trees | Low-level validation and serialization; you own schema construction |
Pydantic provides BaseModel, type-hint interpretation, configuration, decorators, and JSON Schema generation. It builds a core schema, a Python dictionary tree that describes the checks and transformations. pydantic-core, implemented in Rust with Python bindings, compiles that tree into a SchemaValidator and SchemaSerializer. Python callbacks still execute in Python.
python -m venv .venv
# macOS / Linux:
source .venv/bin/activate
# Windows PowerShell instead: .venv\Scripts\Activate.ps1
python -m pip install "pydantic[email]==2.13.5" "pydantic-settings==2.15.0" "email-validator==2.3.0"
python -c "import pydantic, pydantic_core; print(pydantic.__version__, pydantic_core.__version__)"
# 2.13.5 2.46.5Use Python 3.11 or newer. Save a Python code card as example.py and run python example.py; expected output follows each program. The examples use Python 3.12, Pydantic 2.13.5, pydantic-core 2.46.5, pydantic-settings 2.15.0, and email-validator 2.3.0. Older v2 releases may lack some features.
from typing import Annotated
from pydantic import BaseModel, Field, TypeAdapter, ValidationError
# Describe the rule once. This line does not validate a value.
Quantity = Annotated[int, Field(gt=0, le=100)]
# A record: named fields, model methods, and dot access.
class OrderLine(BaseModel):
sku: str
quantity: Quantity
line = OrderLine.model_validate({'sku': 'BK-RUST', 'quantity': '3'})
print('model:', type(line).__name__, line.sku, line.quantity)
# A standalone value: the adapter returns an int.
quantity_adapter = TypeAdapter(Quantity)
quantity = quantity_adapter.validate_python('3')
print('one quantity:', quantity, type(quantity).__name__)
# A collection: the same item rule is applied to every quantity.
batch_adapter = TypeAdapter(list[Quantity])
print('quantity batch:', batch_adapter.validate_python(['2', 3]))
try:
batch_adapter.validate_python([2, 0])
except ValidationError as exc:
print('invalid batch:', exc.errors()[0]['loc'], exc.errors()[0]['type'])model: OrderLine BK-RUST 3
one quantity: 3 int
quantity batch: [2, 3]
invalid batch: (1,) greater_thanOrderLine returns a model instance. The two adapters return an integer and a list of integers. All three reuse Quantity; the invalid list fails at index 1 because zero violates gt=0.
Pydantic installs its required pydantic-core version. Upgrade them together. For core-only code, use a separate environment with python -m pip install pydantic-core==2.46.5.
Official references: BaseModel and models, Annotated types, TypeAdapter, and Pydantic/core architecture.
Your first model#
Validate a purchase request before your application uses it.
Inherit from BaseModel. Each annotated attribute becomes a field; a field without a default must be supplied. Use Model(**data) for keyword arguments, or Model.model_validate(data) for a dictionary or other supported object. The result is a model instance whose fields you access with a dot.
from pydantic import BaseModel, Field, ValidationError
class OrderLine(BaseModel):
sku: str
quantity: int = Field(gt=0, le=100)
line = OrderLine.model_validate({'sku': 'BK-RUST', 'quantity': '3'})
print(line.sku, line.quantity, type(line.quantity).__name__)
print(line.model_dump())
assert line.quantity == 3
try:
OrderLine(sku='BK-RUST', quantity=0)
except ValidationError as exc:
print([(e['loc'], e['type']) for e in exc.errors()])BK-RUST 3 int
{'sku': 'BK-RUST', 'quantity': 3}
[(('quantity',), 'greater_than')]sku is a required string. quantity is a required integer constrained by Field; the input "3" becomes 3. model_dump() returns the checked values. Errors identify the field in loc and the failed rule in type.
Field(gt=0, le=100) accepts quantities from 1 to 100. Inventory availability must be checked separately.
Official reference: Your first model.
Fields, constraints, and Annotated#
Express simple rules in types before writing custom functions.
Field adds numeric limits, string patterns, length limits, defaults, aliases, and documentation. Annotated[T, metadata] means “the Python type is T, with extra rules.” Put constraints on each list item when the rule applies to items, and on the list when it applies to its size.
| Rule | Typical use |
|---|---|
gt / ge / lt / le | gt means >; ge means ≥; lt means <; le means ≤ |
multiple_of | Batch sizes or units |
min_length / max_length | String length or number of collection items |
pattern | String shape; anchor with ^ and $ for a whole-string match |
max_digits / decimal_places | Decimal precision |
title / description / examples | JSON Schema documentation, not extra validation |
from typing import Annotated
from decimal import Decimal
from pydantic import BaseModel, Field, StringConstraints, ValidationError
SKU = Annotated[str, StringConstraints(
strip_whitespace=True, min_length=3, max_length=20,
pattern=r'^[A-Z0-9-]+$'
)]
Price = Annotated[Decimal, Field(gt=0, max_digits=8, decimal_places=2)]
class CatalogBatch(BaseModel):
skus: list[SKU] = Field(min_length=1, max_length=50)
unit_price: Price
batch = CatalogBatch(skus=[' BK-RUST '], unit_price='29.90')
print(batch.skus, batch.unit_price)
for data in [
{'skus': ['bad sku'], 'unit_price': '29.90'},
{'skus': ['BK-RUST'], 'unit_price': '1.234'},
]:
try:
CatalogBatch.model_validate(data)
except ValidationError as exc:
print([(e['loc'], e['type']) for e in exc.errors()])['BK-RUST'] 29.90
[(('skus', 0), 'string_pattern_mismatch')]
[(('unit_price',), 'decimal_max_places')]The default Rust regex engine does not support look-around or backreferences. Use simple patterns, a Python validator, or deliberately configure regex_engine="python-re" when needed. Prefer Annotated aliases over new uses of constr/conint.
SKU trims whitespace and checks the stock-code pattern. The list accepts 1–50 items. decimal_places=2 rejects a price with three decimal places; it does not round the price.
Official reference: Fields, constraints, and Annotated.
Required fields, defaults, and null#
A type that allows None does not automatically make a field optional to supply.
| Declaration | May omit? | May pass null / None? |
|---|---|---|
name: str | No | No |
name: str = "Guest" | Yes | No |
name: str | None | No | Yes |
name: str | None = None | Yes | Yes |
A default is used only when a field is missing. Explicit None is a value, not a request for the default. Use a default_factory for values created for each instance. Normal model defaults are not validated by default; use validate_default=True per field or in model configuration.
from pydantic import BaseModel, Field, ValidationError
class SupportTicket(BaseModel):
subject: str
note: str | None
labels: list[str] = Field(default_factory=list)
retries: int = Field(default='2', validate_default=True)
one = SupportTicket(subject='Invoice copy', note=None)
two = SupportTicket(subject='Delivery date', note='Call after 5pm')
one.labels.append('billing')
print(one.note, one.retries, two.labels)
assert two.labels == [] and one.retries == 2
try:
SupportTicket(subject='Invoice copy')
except ValidationError as exc:
print(exc.errors()[0]['loc'], exc.errors()[0]['type'])None 2 []
('note',) missingfrom pydantic import BaseModel, Field
class Subscriber(BaseModel):
email: str
display_name: str = Field(
default_factory=lambda data: data['email'].split('@')[0]
)
print(Subscriber(email='editor@journal.org').display_name)editorA factory that takes one argument receives only fields already validated before it. Put dependencies first. Pydantic copies non-hashable mutable defaults, but default_factory communicates intent and also works consistently with dataclasses.
Official reference: Required fields, defaults, and null.
Strict validation: Python versus JSON#
Choose whether your boundary should accept useful conversions or reject the wrong input type.
Default, or lax, validation converts supported values. Enable strictness on a call with strict=True, on a field with Field(strict=True) or StrictInt, or across a model with ConfigDict(strict=True). Strictness is type-specific: strict JSON dates and UUIDs can still accept strings because JSON has no native date or UUID type.
from datetime import date
from pydantic import TypeAdapter, ValidationError
quantity = TypeAdapter(int)
print('lax:', quantity.validate_python('12'))
print('lax bool and whole float:', quantity.validate_python(True), quantity.validate_python(12.0))
try:
quantity.validate_python(12.5)
except ValidationError as exc:
print('lax fractional float:', exc.errors()[0]['type'])
for raw in ['12', True, 12.0, 12.5]:
try:
quantity.validate_python(raw, strict=True)
except ValidationError as exc:
print(repr(raw), exc.errors()[0]['type'])
assert quantity.validate_python(12, strict=True) == 12
day = TypeAdapter(date)
print('JSON date:', day.validate_json('"2026-09-30"', strict=True))
try:
day.validate_python('2026-09-30', strict=True)
except ValidationError as exc:
print('Python date string:', exc.errors()[0]['type'])lax: 12
lax bool and whole float: 1 12
lax fractional float: int_from_float
'12' int_type
True int_type
12.0 int_type
12.5 int_type
JSON date: 2026-09-30
Python date string: date_typeStrictness on a list field primarily controls the container type. To require strict integers inside it, declare list[StrictInt]. A per-call strict setting can override configured strictness. Read the conversion table for the actual type instead of assuming all conversions disappear.
Under lax integer validation, True can become 1 and an integral float such as 12.0 can become 12; a fractional value such as 12.5 is rejected. Strict integers reject bools, floats, and numeric strings. Use coercion for a form or configuration source that naturally provides strings; use strictness when those type differences matter to your protocol. Strict mode does not remove your custom before validators, so they can still deliberately transform input.
Official reference: Strict validation: Python versus JSON.
Model configuration#
Set a consistent input policy, and understand what it does not protect.
model_config = ConfigDict(...) sets model behavior. Configuration is inherited by subclasses; nested models keep their own configuration. Use a shared base model when several input models should reject unexpected keys. validate_assignment checks later attribute assignment. frozen prevents assigning model attributes but does not freeze a list stored inside a field.
from pydantic import BaseModel, ConfigDict, Field, ValidationError
class RequestModel(BaseModel):
model_config = ConfigDict(
extra='forbid', str_strip_whitespace=True,
validate_assignment=True, validate_default=True
)
class InventoryChange(RequestModel):
sku: str
quantity: int = Field(ge=0)
item = InventoryChange(sku=' BK-RUST ', quantity=5)
print(item.sku)
try:
item.quantity = -1
except ValidationError as exc:
print('assignment:', exc.errors()[0]['type'])
try:
InventoryChange(sku='BK-RUST', quantity=5, admin=True)
except ValidationError as exc:
print('unknown key:', exc.errors()[0]['type'])BK-RUST
assignment: greater_than_equal
unknown key: extra_forbidden| Setting | Meaning / caution |
|---|---|
extra="ignore" | Default: silently discard extra keys |
extra="forbid" | Reject extra keys; useful for write APIs and configuration typos |
extra="allow" | Keep extras in model_extra; type __pydantic_extra__ to validate their values |
revalidate_instances="always" | Recheck existing instances of this model when passed into validation |
arbitrary_types_allowed=True | Check an arbitrary class instance, not its internal contents or a wire-format conversion |
hide_input_in_errors=True | Hide input from printed error text; do not assume errors() is redacted |
from pydantic import BaseModel, ConfigDict, Field, ValidationError
class CachedStock(BaseModel):
quantity: int = Field(ge=0)
cached = CachedStock(quantity=5)
cached.quantity = -1 # Assignment validation is off by default.
print('default instance policy:', CachedStock.model_validate(cached).quantity)
class RecheckedStock(BaseModel):
model_config = ConfigDict(revalidate_instances='always')
quantity: int = Field(ge=0)
changed = RecheckedStock(quantity=5)
changed.quantity = -1
try:
RecheckedStock.model_validate(changed)
except ValidationError as exc:
print('rechecked:', exc.errors()[0]['loc'], exc.errors()[0]['type'])default instance policy: -1
rechecked: ('quantity',) greater_than_equalvalidate_assignment=True checks a later field assignment. revalidate_instances='always' checks an existing instance when it enters validation again; it does not prevent its earlier mutation. Put the revalidation setting on the model whose instances need checking, including nested models. Mutating an item inside a stored list is not a field assignment, so assignment validation alone does not check it.
Official reference: Model configuration.
Nested models and collections#
Check a complete request and locate fields or list items that failed.
Use one model as another model’s field type. Dictionaries become nested model instances automatically. list[T], dict[K, V], tuples, sets, and annotated item types validate each element. Pydantic aggregates many failures into one exception, with loc tuples identifying nested fields and indexes.
from pydantic import BaseModel, Field, ValidationError
class Address(BaseModel):
city: str
postal_code: str = Field(pattern=r'^[0-9]{4}$')
class Line(BaseModel):
sku: str
quantity: int = Field(gt=0)
class Order(BaseModel):
shipping: Address
lines: list[Line] = Field(min_length=1)
order = Order.model_validate({
'shipping': {'city': 'Dhaka', 'postal_code': '1205'},
'lines': [{'sku': 'BK-RUST', 'quantity': '2'}]
})
print(order.shipping.city, order.lines[0].quantity)
try:
Order.model_validate({
'shipping': {'city': 'Dhaka', 'postal_code': 'XX'},
'lines': [{'sku': 'BK-RUST', 'quantity': 0}, {'quantity': 2}]
})
except ValidationError as exc:
for error in exc.errors():
print(error['loc'], error['type'])Dhaka 2
('shipping', 'postal_code') string_pattern_mismatch
('lines', 0, 'quantity') greater_than
('lines', 1, 'sku') missing('lines', 0, 'quantity') identifies the quantity in the first line; ('lines', 1, 'sku') identifies the second line’s stock code. One exception can report several failures. Nested models keep their own configuration, so an outer extra="forbid" does not apply to every nested model.
Official reference: Nested models and collections.
Dates, money, enums, URLs, and other useful types#
Use types that match the real meaning of a value.
Decimal avoids binary floating-point surprises for money; define currency and rounding in your application. AwareDatetime requires a timezone offset, but does not choose your business timezone. UUID, enums, Literal, HttpUrl, paths, IP addresses, and secret types give useful checks without handwritten parsing.
from decimal import Decimal
from enum import Enum
from uuid import UUID
from pydantic import BaseModel, AwareDatetime, HttpUrl, Field, ValidationError
class Currency(str, Enum):
USD = 'USD'
BDT = 'BDT'
class Payment(BaseModel):
id: UUID
amount: Decimal = Field(gt=0, max_digits=10, decimal_places=2)
currency: Currency
paid_at: AwareDatetime
receipt_url: HttpUrl
raw = dict(id='12345678-1234-5678-1234-567812345678',
amount='19.90', currency='USD', paid_at='2026-09-30T10:00:00+06:00',
receipt_url='https://billing.example.com')
payment = Payment.model_validate(raw)
print(payment.amount, payment.currency.value, str(payment.receipt_url))
print(payment.model_dump_json())
try:
Payment.model_validate({**raw, 'paid_at': '2026-09-30T10:00:00'})
except ValidationError as exc:
print(exc.errors()[0]['type'])19.90 USD https://billing.example.com/
{"id":"12345678-1234-5678-1234-567812345678","amount":"19.90","currency":"USD","paid_at":"2026-09-30T10:00:00+06:00","receipt_url":"https://billing.example.com/"}
timezone_awareA syntactically valid URL is not necessarily safe to fetch. Apply your outbound host and network policy separately. Pydantic URL objects are not str subclasses in v2; call str(url) when a downstream library needs a string. URL normalization can add a trailing slash.
Official reference: Dates, money, enums, URLs, and other useful types.
Input methods and output methods#
Pick the input path deliberately and distinguish a Python object from JSON text.
| Method | Input / output |
|---|---|
model_validate(data) | Validate Python objects; return a model |
model_validate_json(raw) | Validate JSON text / bytes; return a model with JSON-mode rules |
model_validate_strings(mapping) | Validate string-valued dictionaries, including nested dictionaries, using JSON-mode conversions |
model_dump() | Return a Python dictionary; values can still be datetime, Decimal, or other Python types |
model_dump(mode="json") | Return a JSON-compatible dictionary, not an encoded JSON string |
model_dump_json() | Return a JSON str |
from datetime import date
from pydantic import BaseModel
class ReportRequest(BaseModel):
account_id: int
day: date
report = ReportRequest.model_validate_json('{"account_id":7,"day":"2026-09-30"}')
print(type(report.model_dump()['day']).__name__)
print(report.model_dump(mode='json'))
print(report.model_dump_json())
form = ReportRequest.model_validate_strings({'account_id': '7', 'day': '2026-09-30'})
assert form == report
print('String mapping matches JSON request')date
{'account_id': 7, 'day': '2026-09-30'}
{"account_id":7,"day":"2026-09-30"}
String mapping matches JSON requestPass model_dump(mode="json") to a response helper that encodes JSON itself. Pass model_dump_json() to an interface that expects already encoded text. dict(model) does not recursively dump nested model values.
from pydantic import BaseModel, Json, ValidationError
class AuditRecord(BaseModel):
event_id: str
metadata: Json[dict[str, int]]
record = AuditRecord(event_id='EVT-7', metadata='{"attempt":2}')
print('stored:', record.metadata)
ordinary = record.model_dump()
print('ordinary dump:', ordinary)
try:
AuditRecord.model_validate(ordinary)
except ValidationError as exc:
print('ordinary dump as input:', exc.errors()[0]['type'])
replay = record.model_dump(round_trip=True)
print('round-trip dump:', replay)
assert AuditRecord.model_validate(replay) == recordstored: {'attempt': 2}
ordinary dump: {'event_id': 'EVT-7', 'metadata': {'attempt': 2}}
ordinary dump as input: json_type
round-trip dump: {'event_id': 'EVT-7', 'metadata': '{"attempt":2}'}Json[T] parses a field containing JSON text and validates its contents as T. Use an ordinary nested type when the input is already a dictionary. Dumps expose the parsed value; round_trip=True preserves JSON text for this field. Custom serializers may still be irreversible.
Official reference: Input methods and output methods.
Partial updates: missing versus null#
An omitted field means leave it alone; an explicit null can mean clear it.
Use a separate update model and model_fields_set to track which fields the caller actually supplied. model_dump(exclude_unset=True) preserves explicit null while removing omitted defaults. Do not use exclude_none=True for this job: it would erase the caller’s request to clear a field.
from pydantic import BaseModel, ConfigDict, Field, field_validator, ValidationError
class Profile(BaseModel):
model_config = ConfigDict(extra='forbid')
display_name: str = Field(min_length=1)
bio: str | None = None
class ProfilePatch(BaseModel):
model_config = ConfigDict(extra='forbid')
display_name: str | None = Field(default=None, min_length=1)
bio: str | None = None
@field_validator('display_name')
@classmethod
def name_cannot_be_cleared(cls, value):
if value is None:
raise ValueError('display_name cannot be null')
return value
def apply_patch(current: Profile, raw: dict) -> Profile:
patch = ProfilePatch.model_validate(raw)
changes = patch.model_dump(exclude_unset=True)
print('changes:', changes)
return Profile.model_validate({**current.model_dump(), **changes})
current = Profile(display_name='Mira', bio='Python developer')
assert apply_patch(current, {}).bio == 'Python developer'
assert apply_patch(current, {'bio': None}).bio is None
updated = apply_patch(current, {'display_name': 'Mira H.'})
print(updated.model_dump())
print(sorted(ProfilePatch(bio=None).model_fields_set))
try:
apply_patch(current, {'display_name': None})
except ValidationError as exc:
print(exc.errors()[0]['loc'], exc.errors()[0]['type'])changes: {}
changes: {'bio': None}
changes: {'display_name': 'Mira H.'}
{'display_name': 'Mira H.', 'bio': 'Python developer'}
['bio']
('display_name',) value_error| Patch input | Meaning | Result |
|---|---|---|
{} | No change requested | Keep the existing name and biography |
{"bio": null} | Clear the biography | Store bio=None |
{"display_name": "Mira H."} | Replace only the name | Keep the existing biography |
{"display_name": null} | Try to clear a required stored value | Reject the patch |
With validate_default disabled, an omitted display_name keeps the None default. Explicit null runs the validator and fails. The final Profile validation checks the merged record.
The merge shown is shallow. For nested objects, decide whether a supplied object replaces the whole field or recursively merges selected fields; define nested patch models accordingly. model_copy(update=...) does not validate updates, so it is not a substitute for checking a write request.
Official reference: Partial updates: missing versus null.
Aliases and external field names#
Accept a partner’s names without letting them dictate your Python attribute names.
alias affects input and, when requested, output. validation_alias affects only input; serialization_alias affects only output. AliasChoices accepts alternate input keys. AliasPath selects a value from a nested input path. In recent v2 releases, prefer validate_by_name/validate_by_alias over new uses of populate_by_name.
from pydantic import BaseModel, ConfigDict, Field, AliasChoices, AliasPath
class Customer(BaseModel):
model_config = ConfigDict(validate_by_name=True, validate_by_alias=True)
customer_id: int = Field(
validation_alias=AliasChoices('customerId', 'legacy_id'),
serialization_alias='customerId'
)
name: str = Field(validation_alias=AliasPath('profile', 'name'))
customer = Customer.model_validate({'legacy_id': '42', 'profile': {'name': 'Mira'}})
print(customer.customer_id, customer.name)
print(customer.model_dump())
print(customer.model_dump(by_alias=True))
assert Customer(customer_id=42, name='Mira') == customer42 Mira
{'customer_id': 42, 'name': 'Mira'}
{'customerId': 42, 'name': 'Mira'}from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
class Shipment(BaseModel):
model_config = ConfigDict(alias_generator=to_camel,
validate_by_name=True, serialize_by_alias=True)
tracking_number: str
estimated_days: int
shipment = Shipment.model_validate({'trackingNumber': 'DH-42', 'estimatedDays': 3})
print(shipment.model_dump()){'trackingNumber': 'DH-42', 'estimatedDays': 3}AliasChoices tries choices in order; it is not a conflict detector when both keys are present. Reject conflicting duplicate representations with a before model validator if your protocol requires it. A field’s explicit alias normally outranks a generated alias; alias_priority controls this.
Official reference: Aliases and external field names.
Unions, Literal, and discriminators#
Give each variant an explicit tag when the payload has several possible shapes.
A | B accepts either type. The default “smart” mode finds a suitable match; its ranking may change between minor releases. Literal["card"] accepts that exact value. A discriminated union uses a field such as kind to select one model, giving predictable parsing and more focused errors.
from typing import Annotated, Literal
from pydantic import BaseModel, Field, TypeAdapter, ValidationError
class CardPayment(BaseModel):
kind: Literal['card']
token: str = Field(min_length=5)
class BankPayment(BaseModel):
kind: Literal['bank']
account_reference: str
PaymentMethod = Annotated[CardPayment | BankPayment, Field(discriminator='kind')]
adapter = TypeAdapter(PaymentMethod)
method = adapter.validate_python({'kind': 'card', 'token': 'tok_123'})
print(type(method).__name__)
for raw in [{'kind': 'cash'}, {'kind': 'card', 'token': 'x'}, {'token': 'tok_123'}]:
try:
adapter.validate_python(raw)
except ValidationError as exc:
print([(e['loc'], e['type']) for e in exc.errors()])
smart = TypeAdapter(int | str).validate_python('42')
ordered = TypeAdapter(Annotated[int | str, Field(union_mode='left_to_right')]).validate_python('42')
print('smart:', repr(smart), type(smart).__name__)
print('left-to-right:', repr(ordered), type(ordered).__name__)CardPayment
[((), 'union_tag_invalid')]
[(('card', 'token'), 'string_too_short')]
[((), 'union_tag_not_found')]
smart: '42' str
left-to-right: 42 intUse Field(union_mode="left_to_right") if the first successful branch must win. A numeric string can then become an integer in an int | str union. With a tag, avoid that ambiguity entirely. Discriminator fields should use Literal, and distinct branches must have unambiguous tags.
Smart union matching keeps "42" as a string. Left-to-right matching accepts the integer branch first and returns 42. A discriminator such as kind="card" selects the branch explicitly; see nested webhook unions for multiple tags.
Official reference: Unions, Literal, and discriminators.
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