Day 17 · ~13m

Discriminated Unions

Using Literal types, Union, and discriminator fields to handle multiple data shapes.

🧑‍💻

What if my data can have different shapes depending on its type? Like a notification that could be an email, SMS, or push notification — each with different fields?

👩‍🏫

This is one of the most powerful Pydantic patterns: discriminated unions. You create separate models for each shape, mark them with a Literal type field, and Pydantic picks the right model automatically:

from pydantic import BaseModel
from typing import Literal, Union

class EmailNotification(BaseModel):
    type: Literal["email"]
    to_address: str
    subject: str
    body: str

class SMSNotification(BaseModel):
    type: Literal["sms"]
    phone_number: str
    message: str

class PushNotification(BaseModel):
    type: Literal["push"]
    device_id: str
    title: str

The Literal["email"] field means that field must always be exactly the string "email". It's a constant that identifies which model to use.

🧑‍💻

How does Pydantic know which model to pick when I validate data?

👩‍🏫

Use a Union with a discriminator — a field that tells Pydantic which variant to try:

from pydantic import TypeAdapter
from typing import Annotated
from pydantic import Discriminator, Tag

Notification = Annotated[
    Union[
        Annotated[EmailNotification, Tag("email")],
        Annotated[SMSNotification, Tag("sms")],
        Annotated[PushNotification, Tag("push")]
    ],
    Discriminator("type")
]

adapter = TypeAdapter(Notification)

result = adapter.validate_python({"type": "sms", "phone_number": "555-1234", "message": "Hello"})
print(type(result))  # <class 'SMSNotification'>

Pydantic looks at the type field, sees "sms", and knows to validate against SMSNotification. No trial-and-error, no ambiguity.

🧑‍💻

That's a lot of typing imports. Is there a simpler way?

👩‍🏫

For the common case where the discriminator is a field on the models, you can use a simpler pattern with a wrapper model:

class NotificationWrapper(BaseModel):
    notification: Union[EmailNotification, SMSNotification, PushNotification] = Field(
        discriminator="type"
    )

data = {"notification": {"type": "email", "to_address": "a@b.com", "subject": "Hi", "body": "Hello"}}
wrapper = NotificationWrapper(**data)
print(type(wrapper.notification))  # EmailNotification

The Field(discriminator="type") tells Pydantic to use the type field to choose the right union member.

🧑‍💻

What's the advantage over just using a big model with lots of Optional fields?

👩‍🏫

Clarity and safety. With Optional fields, nothing stops you from creating an "email" notification without a to_address. With discriminated unions, each shape is a separate model with its own required fields. If type is "email", then to_address, subject, and body are all required — enforced by the type system:

# This would fail — EmailNotification requires subject and body
try:
    adapter.validate_python({"type": "email", "to_address": "a@b.com"})
except Exception as e:
    print("Missing fields caught!")

The discriminator pattern is how you model data that says "I'm one of these shapes" — API responses, event systems, message queues, configuration files.

Practice your skills

Sign up to write and run code in this lesson.

Already have an account? Sign in