Event streams, polymorphic API payloads and LLM tool calls are all tagged unions.
A field typed int | str or Cat | Dog accepts any member. In the default smart mode, Pydantic first looks for an exact type match across all members (so "1" stays a str in int | str), then tries lax validation and picks the best match. With Field(union_mode="left_to_right") it instead takes the first member that succeeds.
For unions of models, plain unions have two problems: every member may be tried, and when all fail you get errors from each member, which is hard to read.
A discriminated union fixes both. Give each model a Literal field with a distinct value and name it as the discriminator:
pet: Cat | Dog = Field(discriminator="kind")Pydantic reads kind, jumps straight to the matching model and reports errors only for that model. A missing or unknown tag raises a clear union_tag_not_found or union_tag_invalid error.
To validate a bare union outside a model, for example a list of events, combine it with Annotated and a TypeAdapter:
Event = Annotated[Click | Scroll, Field(discriminator="type")]When the tag is not a single field, Discriminator(callable) with Tag annotations lets a function choose the member, for example based on which keys are present.
Discriminated unions also produce a clean oneOf with a discriminator mapping in JSON Schema, which OpenAPI clients use to generate typed code.
Example
from typing import Annotated, Literal
from pydantic import BaseModel, Field, TypeAdapter, ValidationError
class Click(BaseModel):
type: Literal["click"]
x: int
y: int
class Scroll(BaseModel):
type: Literal["scroll"]
delta: int
Event = Annotated[Click | Scroll, Field(discriminator="type")]
events = TypeAdapter(list[Event])
evs = events.validate_python([{"type": "click", "x": 1, "y": 2},
{"type": "scroll", "delta": -3}])
print([type(e).__name__ for e in evs]) # ['Click', 'Scroll']
try:
events.validate_python([{"type": "scroll", "delta": "lots"}])
except ValidationError as e:
print(e.errors()[0]["loc"]) # (0, 'scroll', 'delta')Remember
- Smart mode prefers exact type matches across union members
- A Literal tag + Field(discriminator=...) picks the model directly
- Discriminated unions give focused errors and a clean JSON Schema
Watch out for
- Overlapping models in a plain union, where the wrong member silently wins.