Pydantic & core / Chapter 1 of 5

Models, fields, and types

Define Pydantic models with typed fields, defaults, aliases, nested data, and discriminated unions. Handle strict input and partial updates.

12 topicsUpdated
01 / 44

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.

WordMeaning in this guide
Model / fieldA class describing a record / one named value in that record.
ValidationCheck input and produce the declared Python value, or report why it failed.
CoercionAn allowed conversion, such as the string "3" becoming the integer 3.
Serialization / dumpConvert checked values into an output dictionary or JSON representation.
SchemaA description of data rules. Core schemas tell the engine what to check; JSON Schema documents an external contract.
Boundary / payloadA 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.

ToolUse it forWhat you get
Annotated[T, rules]Add or reuse rules on a field/item typeA type description; a model or adapter runs its checks
BaseModelNamed records such as API requests and responsesA validated object, methods, schema, and model tools
TypeAdapterLists, unions, TypedDicts, dataclasses, or one reusable typeValidation, dumping, and JSON Schema without a wrapper model
Direct pydantic-coreSchema engines and integrations that already build schema treesLow-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.

ShellInstall the packages used in the main examples
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.5
Running the examples

Use 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.

PythonUse one quantity rule in a model and two adapters
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'])
TextExpected output
model: OrderLine BK-RUST 3
one quantity: 3 int
quantity batch: [2, 3]
invalid batch: (1,) greater_than
Model and adapter results

OrderLine 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.

Package compatibility

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.

02 / 44

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.

PythonA checked order line, with a rejected quantity
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()])
TextExpected output
BK-RUST 3 int
{'sku': 'BK-RUST', 'quantity': 3}
[(('quantity',), 'greater_than')]
Field validation

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.

Quantity limits

Field(gt=0, le=100) accepts quantities from 1 to 100. Inventory availability must be checked separately.

Official reference: Your first model.

03 / 44

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.

RuleTypical use
gt / ge / lt / legt means >; ge means ≥; lt means <; le means ≤
multiple_ofBatch sizes or units
min_length / max_lengthString length or number of collection items
patternString shape; anchor with ^ and $ for a whole-string match
max_digits / decimal_placesDecimal precision
title / description / examplesJSON Schema documentation, not extra validation
PythonReusable stock codes and a bounded import batch
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()])
TextExpected output
['BK-RUST'] 29.90
[(('skus', 0), 'string_pattern_mismatch')]
[(('unit_price',), 'decimal_max_places')]
Pattern limitations

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.

04 / 44

Required fields, defaults, and null#

A type that allows None does not automatically make a field optional to supply.

DeclarationMay omit?May pass null / None?
name: strNoNo
name: str = "Guest"YesNo
name: str | NoneNoYes
name: str | None = NoneYesYes

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.

PythonA required nullable note and an independent labels list
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'])
TextExpected output
None 2 []
('note',) missing
PythonDerive a default from an earlier validated field
from pydantic import BaseModel, Field

class Subscriber(BaseModel):
    email: str
    display_name: str = Field(
        default_factory=lambda data: data['email'].split('@')[0]
    )

print(Subscriber(email='[email protected]').display_name)
TextExpected output
editor
Field order matters

A 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.

05 / 44

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.

PythonQuantity strictness and the date input distinction
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'])
TextExpected output
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_type
Container strictness

Strictness 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.

Strictness is a policy choice

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.

06 / 44

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.

PythonReject unknown keys and invalid later assignments
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'])
TextExpected output
BK-RUST
assignment: greater_than_equal
unknown key: extra_forbidden
SettingMeaning / 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=TrueCheck an arbitrary class instance, not its internal contents or a wire-format conversion
hide_input_in_errors=TrueHide input from printed error text; do not assume errors() is redacted
PythonRecheck a model that was changed after construction
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'])
TextExpected output
default instance policy: -1
rechecked: ('quantity',) greater_than_equal
Assignment and revalidation solve different problems

validate_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.

07 / 44

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.

PythonValidate an order and locate errors inside its lines
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'])
TextExpected output
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.

08 / 44

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.

PythonValidate a payment instruction with an aware timestamp
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'])
TextExpected output
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_aware
Validate structure, then enforce policy

A 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.

09 / 44

Input methods and output methods#

Pick the input path deliberately and distinguish a Python object from JSON text.

MethodInput / 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
PythonParse JSON and string-valued configuration, then export
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')
TextExpected output
date
{'account_id': 7, 'day': '2026-09-30'}
{"account_id":7,"day":"2026-09-30"}
String mapping matches JSON request
Do not double encode

Pass 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.

PythonRound-trip a field that contains JSON-encoded text
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) == record
TextExpected output
stored: {'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 stored inside a field

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.

10 / 44

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.

PythonApply a profile patch and validate the resulting record
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'])
TextExpected output
changes: {}
changes: {'bio': None}
changes: {'display_name': 'Mira H.'}
{'display_name': 'Mira H.', 'bio': 'Python developer'}
['bio']
('display_name',) value_error
Patch inputMeaningResult
{}No change requestedKeep the existing name and biography
{"bio": null}Clear the biographyStore bio=None
{"display_name": "Mira H."}Replace only the nameKeep the existing biography
{"display_name": null}Try to clear a required stored valueReject the patch
Omitted and explicit values

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.

Nested updates need an explicit merge policy

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.

11 / 44

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.

PythonNormalize old and new customer payloads
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') == customer
TextExpected output
42 Mira
{'customer_id': 42, 'name': 'Mira'}
{'customerId': 42, 'name': 'Mira'}
PythonGenerate camelCase names for an entire API model
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())
TextExpected output
{'trackingNumber': 'DH-42', 'estimatedDays': 3}
Ambiguous alternate keys

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.

12 / 44

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.

PythonChoose between card and bank payment requests
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__)
TextExpected output
CardPayment
[((), 'union_tag_invalid')]
[(('card', 'token'), 'string_too_short')]
[((), 'union_tag_not_found')]
smart: '42' str
left-to-right: 42 int
When left-to-right is intentional

Use 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.