{"slug": "pydantic-v2-discriminated-unions-in-fastapi-modeling-polymorphic-ai-feature", "title": "Pydantic V2 Discriminated Unions in FastAPI: Modeling Polymorphic AI Feature Configs Without Schema Sprawl", "summary": "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.", "body_md": "I've built nine AI features into CitizenApp, and each one has a wildly different configuration shape. A summarization feature needs `max_tokens`\n\nand `style`\n\n. A classifier needs `labels`\n\nand `confidence_threshold`\n\n. A generator needs `temperature`\n\n, `system_prompt`\n\n, and `output_format`\n\n.\n\nFor 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.\n\nThen 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.\n\nThis is how I'd tell my past self to do it.\n\nHere's what my old code looked like:\n\n``` python\n# ❌ Before: Polymorphism via conditionals\nfrom fastapi import FastAPI, HTTPException\nfrom pydantic import BaseModel\n\napp = FastAPI()\n\nclass FeatureConfig(BaseModel):\n    feature_type: str\n    config: dict  # God object antipattern\n\n@app.post(\"/features\")\nasync def create_feature(payload: FeatureConfig):\n    if payload.feature_type == \"summarization\":\n        if \"max_tokens\" not in payload.config:\n            raise HTTPException(400, \"missing max_tokens\")\n        max_tokens = payload.config[\"max_tokens\"]\n        # ...\n    elif payload.feature_type == \"classification\":\n        if \"labels\" not in payload.config:\n            raise HTTPException(400, \"missing labels\")\n        labels = payload.config[\"labels\"]\n        # ...\n    else:\n        raise HTTPException(400, \"unknown feature type\")\n```\n\nThis burns in three ways:\n\n`payload.config`\n\nis a `dict`\n\n. The type checker doesn't know what keys exist. You discover missing fields at runtime.`classification`\n\nconfig actually needs. They trial-and-error until something works.Pydantic V2's `Discriminator`\n\nfield 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:\n\n``` python\n# ✅ After: Discriminated unions\nfrom pydantic import BaseModel, Field\nfrom typing import Annotated, Literal, Union\nfrom fastapi import FastAPI\n\napp = FastAPI()\n\n# Each feature type is its own model\nclass SummarizationConfig(BaseModel):\n    feature_type: Literal[\"summarization\"]\n    max_tokens: int = Field(gt=0, le=4096)\n    style: Literal[\"bullet\", \"paragraph\", \"executive\"] = \"paragraph\"\n\nclass ClassificationConfig(BaseModel):\n    feature_type: Literal[\"classification\"]\n    labels: list[str] = Field(min_items=2, max_items=50)\n    confidence_threshold: float = Field(ge=0, le=1)\n    allow_multi_label: bool = False\n\nclass GenerationConfig(BaseModel):\n    feature_type: Literal[\"generation\"]\n    temperature: float = Field(ge=0, le=2)\n    system_prompt: str = Field(min_length=10)\n    output_format: Literal[\"text\", \"json\", \"markdown\"] = \"text\"\n    max_tokens: int = Field(gt=0, le=8000)\n\n# Union of all variants, discriminated by feature_type\nAIFeatureConfig = Annotated[\n    Union[SummarizationConfig, ClassificationConfig, GenerationConfig],\n    Field(discriminator=\"feature_type\")\n]\n\n@app.post(\"/features\")\nasync def create_feature(config: AIFeatureConfig):\n    # config is already the correct type\n    # Type checker knows exactly what fields exist\n    if isinstance(config, SummarizationConfig):\n        print(config.max_tokens)  # Type checker sees this exists\n        print(config.style)\n    elif isinstance(config, ClassificationConfig):\n        print(config.labels)\n        print(config.confidence_threshold)\n    elif isinstance(config, GenerationConfig):\n        print(config.temperature)\n        print(config.system_prompt)\n```\n\nWhat just happened:\n\n`feature_type`\n\n, routes to the correct model, validates all fields. If a client sends `classification`\n\nwith missing `labels`\n\n, they get a `422`\n\nresponse with a clear error message—before your handler runs.`isinstance(config, SummarizationConfig)`\n\n, the type checker knows `config.max_tokens`\n\nexists. No `dict`\n\ncasting, no runtime guessing.In CitizenApp, feature configs live in PostgreSQL as JSONB. Here's how I handle polymorphic queries:\n\n``` python\n# models.py\nfrom sqlalchemy import Column, String, JSON\nfrom sqlalchemy.orm import DeclarativeBase\n\nclass Base(DeclarativeBase):\n    pass\n\nclass AIFeature(Base):\n    __tablename__ = \"ai_features\"\n\n    id = Column(String, primary_key=True)\n    feature_type = Column(String, nullable=False, index=True)\n    config = Column(JSON, nullable=False)  # Stored as JSONB\n\n# crud.py\nfrom sqlalchemy.orm import Session\nfrom pydantic import ValidationError\n\nasync def create_feature(db: Session, config: AIFeatureConfig) -> AIFeature:\n    \"\"\"\n    The discriminated union validated the config shape.\n    Now we just store it.\n    \"\"\"\n    db_feature = AIFeature(\n        id=generate_id(),\n        feature_type=config.feature_type,\n        config=config.model_dump()  # Always valid\n    )\n    db.add(db_feature)\n    db.commit()\n    return db_feature\n\nasync def get_feature(db: Session, feature_id: str) -> AIFeatureConfig:\n    \"\"\"\n    Retrieve from DB and re-validate against the union.\n    \"\"\"\n    row = db.query(AIFeature).filter(AIFeature.id == feature_id).one()\n\n    # This will fail loudly if the DB contains a schema mismatch\n    # (which shouldn't happen, but you catch accidental updates)\n    config = AIFeatureConfig.model_validate(\n        {\"feature_type\": row.feature_type, **row.config}\n    )\n    return config\n```\n\nWhy this matters: If someone accidentally updates the database with a malformed config, `model_validate`\n\ncatches it immediately. You don't ship a broken feature to production because a schema migration went sideways.\n\nNow that config is type-safe, Claude calls are cleaner:\n\n``` python\n# ai_service.py\nimport anthropic\n\nasync def invoke_feature(config: AIFeatureConfig, input_text: str) -> str:\n    \"\"\"\n    Type narrowing means we know exactly what fields exist.\n    No runtime schema lookups, no conditional prompt building.\n    \"\"\"\n    client = anthropic.Anthropic()\n\n    if isinstance(config, SummarizationConfig):\n        prompt = f\"\"\"Summarize the following in {config.style} format.\nMax output: {config.max_tokens} tokens.\n\n{input_text}\"\"\"\n\n    elif isinstance(config, ClassificationConfig):\n        prompt = f\"\"\"Classify the following text into one of these categories:\n{', '.join(config.labels)}\n\nConfidence threshold: {config.confidence_threshold}\nMulti-label allowed: {config.allow_multi_label}\n\n{input_text}\"\"\"\n\n    elif isinstance(config, GenerationConfig):\n        prompt = f\"\"\"{config.system_prompt}\n\nUser input: {input_text}\n\nRespond in {config.output_format} format.\"\"\"\n\n    response = client.messages.create(\n        model=\"claude-3-5-sonnet-20241022\",\n        max_tokens=config.max_tokens if hasattr(config, \"max_tokens\") else 1024,\n        messages=[{\"role\": \"user\", \"content\": prompt}]\n    )\n\n    return response.content[0].text\n```\n\nThis 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.\n\nI learned this the hard way. If you have nested discriminated unions, **the discriminator field must be the same across all levels:**\n\n```\n# ❌ This breaks\nclass OuterConfig(BaseModel):\n    config_type: Literal[\"outer\"]  # Different name!\n    inner: AIFeatureConfig\n\n# ✅ This works\nclass OuterConfig(BaseModel):\n    feature_type: Literal[\"outer\"]  # Same discriminator name\n    inner: AIFeatureConfig\n```\n\nAlso: Pydantic uses the discriminator value for routing, so if your models have overlapping `Literal`\n\nvalues, validation becomes ambiguous. Keep discriminator values unique across your entire union tree.\n\nI didn't plan for schema evolution. A year in, I needed to add a new field to `ClassificationConfig`\n\n. I should have baked in a `schema_version`\n\nfield from the start:\n\n```\nclass ClassificationConfig(BaseModel):\n    feature_type: Literal[\"classification\"]\n    schema_version: Literal[1] = 1\n    labels: list[str]\n    confidence_threshold: float\n```\n\nThen when v2 ships, I can fork the union and migrate gradually.", "url": "https://wpnews.pro/news/pydantic-v2-discriminated-unions-in-fastapi-modeling-polymorphic-ai-feature", "canonical_source": "https://dev.to/uaslimcreate/pydantic-v2-discriminated-unions-in-fastapi-modeling-polymorphic-ai-feature-configs-without-schema-93e", "published_at": "2026-06-03 08:00:03+00:00", "updated_at": "2026-06-03 08:13:04.615552+00:00", "lang": "en", "topics": ["artificial-intelligence", "machine-learning", "large-language-models", "ai-products", "ai-tools"], "entities": ["Pydantic", "FastAPI", "CitizenApp", "Claude"], "alternates": {"html": "https://wpnews.pro/news/pydantic-v2-discriminated-unions-in-fastapi-modeling-polymorphic-ai-feature", "markdown": "https://wpnews.pro/news/pydantic-v2-discriminated-unions-in-fastapi-modeling-polymorphic-ai-feature.md", "text": "https://wpnews.pro/news/pydantic-v2-discriminated-unions-in-fastapi-modeling-polymorphic-ai-feature.txt", "jsonld": "https://wpnews.pro/news/pydantic-v2-discriminated-unions-in-fastapi-modeling-polymorphic-ai-feature.jsonld"}}