Type-safe discriminated unions for Pydantic models.
While several libraries offer partial solutions to handling polymorphic data structures, pydantic-discriminated stands out by providing:
Most alternatives either lack proper type information, don’t support nested structures, or require complex manual configuration. pydantic-discriminated solves these limitations with a clean, type-safe API that feels like a natural extension of Pydantic itself.
| Feature | pydantic-discriminated | pydantic TaggedUnion | python-union | pydantic-factories | cattrs + attrs | marshmallow + marshmallow-oneofschema |
|---|---|---|---|---|---|---|
| Type Safety | ✅ Full type-checking support | ⚠️ Limited | ⚠️ Partial | ⚠️ Limited | ⚠️ Partial | ❌ No |
| Nested Models | ✅ Arbitrary nesting levels | ✅ Supported | ❌ Limited | ❌ No | ⚠️ Limited | ⚠️ Limited |
| IDE Support | ✅ Full autocomplete | ⚠️ Partial | ⚠️ Partial | ⚠️ Partial | ⚠️ Partial | ❌ No |
| Runtime Control | ✅ Flexible configuration | ❌ No | ❌ No | ❌ No | ⚠️ Limited | ⚠️ Limited |
| OpenAPI Support | ✅ Complete | ✅ Basic | ⚠️ Manual setup | ❌ No | ❌ No | ⚠️ Partial |
| Serialization Control | ✅ Per-call options | ❌ No | ❌ No | ❌ No | ⚠️ Limited | ⚠️ Limited |
| Standard Fields | ✅ Configurable | ❌ No | ❌ No | ❌ No | ❌ No | ❌ No |
| Validation | ✅ Full Pydantic validation | ✅ Full Pydantic validation | ⚠️ Basic | ⚠️ Limited | ✅ Supported | ✅ Supported |
| Enum Support | ✅ Native Enum integration | ❌ No | ❌ No | ❌ No | ⚠️ Manual | ⚠️ Manual |
| Monkey Patching | ✅ Optional & configurable | ❌ No | ❌ No | ❌ No | ❌ No | ❌ No |
| FastAPI Integration | ✅ Seamless | ⚠️ Basic | ⚠️ Manual setup | ❌ No | ❌ No | ⚠️ Limited |
| Learning Curve | ✅ Simple decorator pattern | ⚠️ Moderate | ⚠️ Moderate | ⚠️ Steep | ⚠️ Steep | ⚠️ Steep |
| Pydantic v2 Support | ✅ Full support | ✅ Supported | ❌ Limited | ⚠️ Partial | ❓ Unknown | ❓ Unknown |
Discriminated unions (also called tagged unions) let you work with polymorphic data in a type-safe way. A “discriminator” field tells you which concrete type you’re dealing with.
from pydantic_discriminated import discriminated_model, DiscriminatedBaseModel
@discriminated_model("shape_type", "circle")
class Circle(DiscriminatedBaseModel):
radius: float
def area(self) -> float:
return 3.14159 * self.radius ** 2
@discriminated_model("shape_type", "rectangle")
class Rectangle(DiscriminatedBaseModel):
width: float
height: float
def area(self) -> float:
return self.width * self.height
# Parse data with the correct type
data = {"shape_type": "circle", "radius": 5}
circle = Circle.model_validate(data) # Fully typed as Circle
print(f"Area: {circle.area()}") # 78.53975
model_validate, model_dump)pip install pydantic-discriminated
pydantic-discriminated uses a combination of techniques to provide powerful discriminated union functionality:
With monkey patching enabled (the default), discriminator fields are automatically included:
from pydantic import BaseModel
from pydantic_discriminated import discriminated_model, DiscriminatedBaseModel
@discriminated_model("shape_type", "circle")
class Circle(DiscriminatedBaseModel):
radius: float
# Regular BaseModel works automatically
class Container(BaseModel):
my_shape: Circle
container = Container(my_shape=Circle(radius=5))
data = container.model_dump()
# Includes shape_type automatically:
# {"my_shape": {"radius": 5, "shape_type": "circle", ...}}
For more control, you can disable monkey patching and use DiscriminatorAwareBaseModel:
from pydantic_discriminated import (
discriminated_model, DiscriminatedBaseModel,
DiscriminatorAwareBaseModel, DiscriminatedConfig
)
# Disable automatic patching
DiscriminatedConfig.disable_monkey_patching()
@discriminated_model("shape_type", "circle")
class Circle(DiscriminatedBaseModel):
radius: float
# Use the aware base model for containers
class Container(DiscriminatorAwareBaseModel):
my_shape: Circle
container = Container(my_shape=Circle(radius=5))
data = container.model_dump()
# Still includes shape_type:
# {"my_shape": {"radius": 5, "shape_type": "circle", ...}}
Define discriminated models for different event types:
from enum import Enum
from typing import List, Union
from pydantic import BaseModel
from pydantic_discriminated import discriminated_model, DiscriminatedBaseModel
class EventType(str, Enum):
USER_CREATED = "user_created"
USER_UPDATED = "user_updated"
LOGIN_ATTEMPT = "login_attempt"
@discriminated_model(EventType, EventType.USER_CREATED)
class UserCreatedEvent(DiscriminatedBaseModel):
user_id: str
username: str
@discriminated_model(EventType, EventType.USER_UPDATED)
class UserUpdatedEvent(DiscriminatedBaseModel):
user_id: str
fields_changed: List[str]
@discriminated_model(EventType, EventType.LOGIN_ATTEMPT)
class LoginAttemptEvent(DiscriminatedBaseModel):
user_id: str
success: bool
ip_address: str
# Container that handles any event type
class EventProcessor(BaseModel):
events: List[Union[UserCreatedEvent, UserUpdatedEvent, LoginAttemptEvent]]
def process(self):
for event in self.events:
if isinstance(event, UserCreatedEvent):
print(f"New user created: {event.username}")
elif isinstance(event, UserUpdatedEvent):
print(f"User {event.user_id} updated fields: {event.fields_changed}")
elif isinstance(event, LoginAttemptEvent):
result = "succeeded" if event.success else "failed"
print(f"Login {result} for user {event.user_id} from {event.ip_address}")
You can control discriminator field inclusion on a per-call basis:
# Always include discriminator fields (with monkey patching enabled)
data = shape.model_dump()
# Explicitly control discriminator inclusion
with_disc = shape.model_dump(use_discriminators=True)
without_disc = shape.model_dump(use_discriminators=False)
This library is perfect for:
MIT
Built by Talbot Knighton