Unions and discriminated unions

Pydantic · Advanced · 12 min

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.

Check yourself

  1. What is the main benefit of a discriminated union over a plain Cat | Dog union?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.pydantic.dev/latest/concepts/unions/