Pydantic V2 Discriminated Unions in FastAPI: Modeling Polymorphic AI Feature Configs Without Schema Sprawl A developer at CitizenApp replaced if/elif chains in FastAPI route handlers with Pydantic V2's discriminated unions to handle nine different AI feature configurations. The approach eliminates runtime schema mismatches by validating each feature type—such as summarization, classification, and generation—against its own typed model at the HTTP layer. This change turns previously error-prone polymorphic config handling into type-safe, one-line route definitions that catch invalid client requests before they reach the API call. I've built nine AI features into CitizenApp, and each one has a wildly different configuration shape. A summarization feature needs max tokens and style . A classifier needs labels and confidence threshold . A generator needs temperature , system prompt , and output format . For months, I solved this with if/elif chains in my route handlers. It was a disaster. Schema mismatches lived in production. Clients sent invalid configs that slipped past validation. I'd catch them at runtime inside the Claude API call—expensive, embarrassing, and hard to debug. Then I switched to Pydantic V2's discriminated unions. Now my FastAPI routes are one-liners. My database queries are type-safe. And every schema mismatch gets caught at the HTTP layer, not buried in a traceback three API calls deep. This is how I'd tell my past self to do it. Here's what my old code looked like: python ❌ Before: Polymorphism via conditionals from fastapi import FastAPI, HTTPException from pydantic import BaseModel app = FastAPI class FeatureConfig BaseModel : feature type: str config: dict God object antipattern @app.post "/features" async def create feature payload: FeatureConfig : if payload.feature type == "summarization": if "max tokens" not in payload.config: raise HTTPException 400, "missing max tokens" max tokens = payload.config "max tokens" ... elif payload.feature type == "classification": if "labels" not in payload.config: raise HTTPException 400, "missing labels" labels = payload.config "labels" ... else: raise HTTPException 400, "unknown feature type" This burns in three ways: payload.config is a dict . The type checker doesn't know what keys exist. You discover missing fields at runtime. classification config actually needs. They trial-and-error until something works.Pydantic V2's Discriminator field makes polymorphism a first-class citizen. You define each variant as its own model, tag it with a discriminator field, and Pydantic does the rest: python ✅ After: Discriminated unions from pydantic import BaseModel, Field from typing import Annotated, Literal, Union from fastapi import FastAPI app = FastAPI Each feature type is its own model class SummarizationConfig BaseModel : feature type: Literal "summarization" max tokens: int = Field gt=0, le=4096 style: Literal "bullet", "paragraph", "executive" = "paragraph" class ClassificationConfig BaseModel : feature type: Literal "classification" labels: list str = Field min items=2, max items=50 confidence threshold: float = Field ge=0, le=1 allow multi label: bool = False class GenerationConfig BaseModel : feature type: Literal "generation" temperature: float = Field ge=0, le=2 system prompt: str = Field min length=10 output format: Literal "text", "json", "markdown" = "text" max tokens: int = Field gt=0, le=8000 Union of all variants, discriminated by feature type AIFeatureConfig = Annotated Union SummarizationConfig, ClassificationConfig, GenerationConfig , Field discriminator="feature type" @app.post "/features" async def create feature config: AIFeatureConfig : config is already the correct type Type checker knows exactly what fields exist if isinstance config, SummarizationConfig : print config.max tokens Type checker sees this exists print config.style elif isinstance config, ClassificationConfig : print config.labels print config.confidence threshold elif isinstance config, GenerationConfig : print config.temperature print config.system prompt What just happened: feature type , routes to the correct model, validates all fields. If a client sends classification with missing labels , they get a 422 response with a clear error message—before your handler runs. isinstance config, SummarizationConfig , the type checker knows config.max tokens exists. No dict casting, no runtime guessing.In CitizenApp, feature configs live in PostgreSQL as JSONB. Here's how I handle polymorphic queries: python models.py from sqlalchemy import Column, String, JSON from sqlalchemy.orm import DeclarativeBase class Base DeclarativeBase : pass class AIFeature Base : tablename = "ai features" id = Column String, primary key=True feature type = Column String, nullable=False, index=True config = Column JSON, nullable=False Stored as JSONB crud.py from sqlalchemy.orm import Session from pydantic import ValidationError async def create feature db: Session, config: AIFeatureConfig - AIFeature: """ The discriminated union validated the config shape. Now we just store it. """ db feature = AIFeature id=generate id , feature type=config.feature type, config=config.model dump Always valid db.add db feature db.commit return db feature async def get feature db: Session, feature id: str - AIFeatureConfig: """ Retrieve from DB and re-validate against the union. """ row = db.query AIFeature .filter AIFeature.id == feature id .one This will fail loudly if the DB contains a schema mismatch which shouldn't happen, but you catch accidental updates config = AIFeatureConfig.model validate {"feature type": row.feature type, row.config} return config Why this matters: If someone accidentally updates the database with a malformed config, model validate catches it immediately. You don't ship a broken feature to production because a schema migration went sideways. Now that config is type-safe, Claude calls are cleaner: python ai service.py import anthropic async def invoke feature config: AIFeatureConfig, input text: str - str: """ Type narrowing means we know exactly what fields exist. No runtime schema lookups, no conditional prompt building. """ client = anthropic.Anthropic if isinstance config, SummarizationConfig : prompt = f"""Summarize the following in {config.style} format. Max output: {config.max tokens} tokens. {input text}""" elif isinstance config, ClassificationConfig : prompt = f"""Classify the following text into one of these categories: {', '.join config.labels } Confidence threshold: {config.confidence threshold} Multi-label allowed: {config.allow multi label} {input text}""" elif isinstance config, GenerationConfig : prompt = f"""{config.system prompt} User input: {input text} Respond in {config.output format} format.""" response = client.messages.create model="claude-3-5-sonnet-20241022", max tokens=config.max tokens if hasattr config, "max tokens" else 1024, messages= {"role": "user", "content": prompt} return response.content 0 .text This is the real win: once validation passes, your business logic doesn't need defensive coding. No checking if fields exist. No runtime type guessing. Just type-safe field access. I learned this the hard way. If you have nested discriminated unions, the discriminator field must be the same across all levels: ❌ This breaks class OuterConfig BaseModel : config type: Literal "outer" Different name inner: AIFeatureConfig ✅ This works class OuterConfig BaseModel : feature type: Literal "outer" Same discriminator name inner: AIFeatureConfig Also: Pydantic uses the discriminator value for routing, so if your models have overlapping Literal values, validation becomes ambiguous. Keep discriminator values unique across your entire union tree. I didn't plan for schema evolution. A year in, I needed to add a new field to ClassificationConfig . I should have baked in a schema version field from the start: class ClassificationConfig BaseModel : feature type: Literal "classification" schema version: Literal 1 = 1 labels: list str confidence threshold: float Then when v2 ships, I can fork the union and migrate gradually.