Discriminated Unions in OpenAPI with Pydantic

Key takeaways:

  • Field(discriminator="method") makes Pydantic read one literal field and validate a single variant.
  • The generated schema becomes oneOf plus a discriminator mapping, which client generators can narrow on.
  • An untagged union reports failures for every member; a tagged one reports only the variant you sent.
  • An unknown tag produces one union_tag_invalid error listing the tags that were acceptable.
  • The discriminator must be a Literal field, spelled identically on every member, and never optional.

This guide extends JSON Schema Customization, which covers shaping the generated document; here the shape change also transforms the error responses your API returns.

The Problem This Solves

A payments endpoint accepts a card, a bank transfer or a wallet. The natural annotation is Union[CardPayment, BankTransferPayment, WalletPayment], and it works — right up until someone sends a card with a two-digit number. Then they get a 422 containing eight errors, six of which complain about missing IBANs and wallet tokens for payment methods they were never attempting.

A support engineer reading that body cannot tell what went wrong. Neither can the client developer, and neither can an error-tracking dashboard trying to group failures by cause.

Untagged versus tagged union validationOn the left an untagged union validates the payload against all three members and returns eight errors. On the right a discriminated union reads the method tag, validates only the card variant, and returns two errors.Union[...] — anyOfdiscriminator="method" — oneOfbody.paymentCardBankTransferWallet422 with 8 errorsbody.paymentread method — "card"CardPayment only422 with 2 errorsSame payload, same models — only the annotation differs.

Why It Happens

Pydantic's smart union mode tries each member in turn. When every member fails, it has no basis for deciding which failure the caller cares about, so it returns all of them, tagged with the member's class name in the error location. The eight-error body is not a bug; it is the only honest answer to "this matched nothing" when nothing indicated what it was supposed to match.

Adding Field(discriminator="method") changes the question. Pydantic builds a tagged-union validator that reads the method key from the raw input before any member validation runs, looks it up in a mapping built from the Literal annotations, and dispatches to exactly one member. If the tag is missing or unrecognised, validation stops there with a single error about the tag itself.

That short-circuit is why the error quality improves so much: once the tag says card, every subsequent error is unambiguously about card payments, and the error location says card rather than naming a class the caller has never heard of.

The schema changes to match. An untagged union generates anyOf with a list of $refs — "any of these will do", which tells a code generator nothing about how to pick. A discriminated union generates oneOf plus a discriminator object containing propertyName and an explicit mapping from each tag value to its schema reference. That mapping is a machine-readable dispatch table, and generators for TypeScript, Kotlin and Go all consume it to emit narrowable tagged types.

There is a performance dimension too, though it is the least interesting one: dispatching on a dictionary lookup instead of attempting three full model validations is strictly less work. The correctness and ergonomics are the reasons to do it.

The Fix

Every member gets a Literal field with the same name and a distinct value. The union is annotated once and reused.

"""Compare the 422 from a tagged (discriminated) union with the one from a plain union."""
from typing import Annotated, Literal, Union

from fastapi import FastAPI
from pydantic import BaseModel, Field


class CardPayment(BaseModel):
    method: Literal["card"]
    card_number: str = Field(min_length=12, max_length=19)
    cvc: str = Field(min_length=3, max_length=4)


class BankTransferPayment(BaseModel):
    method: Literal["bank_transfer"]
    iban: str = Field(min_length=15)
    reference: str


class WalletPayment(BaseModel):
    method: Literal["wallet"]
    wallet_id: str
    device_token: str


Tagged = Annotated[
    Union[CardPayment, BankTransferPayment, WalletPayment],
    Field(discriminator="method"),
]
Untagged = Union[CardPayment, BankTransferPayment, WalletPayment]


class TaggedEnvelope(BaseModel):
    amount_cents: int
    payment: Tagged


class UntaggedEnvelope(BaseModel):
    amount_cents: int
    payment: Untagged

Both envelopes are served from the same app, so the comparison below is genuinely like-for-like: identical models, identical payload, one annotation different.

The tagged 422

$ POST /tagged  {"amount_cents": 1999, "payment": {"method": "card", "card_number": "42", "cvc": "1"}}
422 Unprocessable Entity
{
  "detail": [
    {
      "type": "string_too_short",
      "loc": [
        "body",
        "payment",
        "card",
        "card_number"
      ],
      "msg": "String should have at least 12 characters",
      "input": "42",
      "ctx": {
        "min_length": 12
      }
    },
    {
      "type": "string_too_short",
      "loc": [
        "body",
        "payment",
        "card",
        "cvc"
      ],
      "msg": "String should have at least 3 characters",
      "input": "1",
      "ctx": {
        "min_length": 3
      }
    }
  ]
}

Two errors, both true, both about the thing the caller actually sent. The location path reads body.payment.card.card_number — the tag value appears as the path segment, so a client can map the error straight onto its own form field without knowing your class names.

The untagged 422, same payload

$ POST /untagged  {"amount_cents": 1999, "payment": {"method": "card", "card_number": "42", "cvc": "1"}}
422 Unprocessable Entity
{
  "detail": [
    {
      "type": "string_too_short",
      "loc": [
        "body",
        "payment",
        "CardPayment",
        "card_number"
      ],
      "msg": "String should have at least 12 characters",
      "input": "42",
      "ctx": {
        "min_length": 12
      }
    },
    {
      "type": "string_too_short",
      "loc": [
        "body",
        "payment",
        "CardPayment",
        "cvc"
      ],
      "msg": "String should have at least 3 characters",
      "input": "1",
      "ctx": {
        "min_length": 3
      }
    },
    {
      "type": "literal_error",
      "loc": [
        "body",
        "payment",
        "BankTransferPayment",
        "method"
      ],
      "msg": "Input should be 'bank_transfer'",
      "input": "card",
      "ctx": {
        "expected": "'bank_transfer'"
      }
    },
    {
      "type": "missing",
      "loc": [
        "body",
        "payment",
        "BankTransferPayment",
        "iban"
      ],
      "msg": "Field required",
      "input": {
        "method": "card",
        "card_number": "42",
        "cvc": "1"
      }
    },
    {
      "type": "missing",
      "loc": [
        "body",
        "payment",
        "BankTransferPayment",
        "reference"
      ],
      "msg": "Field required",
      "input": {
        "method": "card",
        "card_number": "42",
        "cvc": "1"
      }
    },
    {
      "type": "literal_error",
      "loc": [
        "body",
        "payment",
        "WalletPayment",
        "method"
      ],
      "msg": "Input should be 'wallet'",
      "input": "card",
      "ctx": {
        "expected": "'wallet'"
      }
    },
    {
      "type": "missing",
      "loc": [
        "body",
        "payment",
        "WalletPayment",
        "wallet_id"
      ],
      "msg": "Field required",
      "input": {
        "method": "card",
        "card_number": "42",
        "cvc": "1"
      }
    },
    {
      "type": "missing",
      "loc": [
        "body",
        "payment",
        "WalletPayment",
        "device_token"
      ],
      "msg": "Field required",
      "input": {
        "method": "card",
        "card_number": "42",
        "cvc": "1"
      }
    }
  ]
}

Eight errors for one mistake. Six of them tell a caller who is paying by card that they forgot their IBAN. The class names leak into the response, so CardPayment — an implementation detail — becomes part of your public error contract.

The unknown tag

The tagged version also gets an error class the untagged one cannot produce at all:

$ POST /tagged  {"amount_cents": 1999, "payment": {"method": "cheque", "amount": 1}}
422 Unprocessable Entity
{
  "detail": [
    {
      "type": "union_tag_invalid",
      "loc": [
        "body",
        "payment"
      ],
      "msg": "Input tag 'cheque' found using 'method' does not match any of the expected tags: 'card', 'bank_transfer', 'wallet'",
      "input": {
        "method": "cheque",
        "amount": 1
      },
      "ctx": {
        "discriminator": "'method'",
        "tag": "cheque",
        "expected_tags": "'card', 'bank_transfer', 'wallet'"
      }
    }
  ]
}

One error that names what was sent, names the field it was read from, and lists every acceptable value. There is nothing left for the caller to guess. The untagged union's answer to the same request is another eight-error pile-up.

The generated schema

$ GET /_meta/schema
200 OK
{
  "tagged_payment_property": {
    "oneOf": [
      {
        "$ref": "#/components/schemas/CardPayment"
      },
      {
        "$ref": "#/components/schemas/BankTransferPayment"
      },
      {
        "$ref": "#/components/schemas/WalletPayment"
      }
    ],
    "title": "Payment",
    "discriminator": {
      "propertyName": "method",
      "mapping": {
        "bank_transfer": "#/components/schemas/BankTransferPayment",
        "card": "#/components/schemas/CardPayment",
        "wallet": "#/components/schemas/WalletPayment"
      }
    }
  },
  "untagged_payment_property": {
    "anyOf": [
      {
        "$ref": "#/components/schemas/CardPayment"
      },
      {
        "$ref": "#/components/schemas/BankTransferPayment"
      },
      {
        "$ref": "#/components/schemas/WalletPayment"
      }
    ],
    "title": "Payment"
  }
}

The mapping is the deliverable. A TypeScript generator reading it emits a discriminated union that narrows on payment.method, so a consumer writing if (payment.method === "card") gets card_number autocompleted. Reading the anyOf version, the best a generator can do is a bare union the caller must interrogate by hand.

Verification

Assert the schema shape, because it is what downstream generators consume:

def test_schema_carries_a_discriminator_mapping():
    prop = app.openapi()["components"]["schemas"]["TaggedEnvelope"]["properties"]["payment"]
    assert prop["discriminator"]["propertyName"] == "method"
    assert set(prop["discriminator"]["mapping"]) == {"card", "bank_transfer", "wallet"}

Then assert the error quality, which is the property that regresses silently if somebody adds a member without a Literal or drops the Field(discriminator=...) during a refactor:

def test_bad_card_reports_only_card_errors(client):
    body = {"amount_cents": 1, "payment": {"method": "card", "card_number": "42", "cvc": "1"}}
    detail = client.post("/tagged", json=body).json()["detail"]
    assert all(error["loc"][2] == "card" for error in detail)
    assert len(detail) == 2

A final guard worth having: assert that the mapping covers every member. A new variant added to the union but missing from the tag literals is a runtime error the moment someone sends it, and a one-line test catches it at build time.

Trade-offs and When Not To

The discriminator field must exist and must be literal. Every member needs the same field name annotated with Literal, non-optional, with a distinct value. If your payload does not already carry a type tag, adding one is a request-format change your clients have to make — real work, and the main reason to design the tag in from the start.

Some payloads genuinely have no tag. Accepting either {"id": 1} or {"slug": "abc"} is structural, not tagged. There a plain union is correct, and the way to improve the error is a custom validator that inspects the keys and raises one clear message. Cross-Field Validation Patterns covers that shape.

Adding a variant is a schema change. New tags are additive for servers and breaking for strict clients, which will reject an unrecognised tag. Plan the rollout: ship the client's tolerance for unknown tags before you ship the tag.

Class names leak in untagged unions. This is worth restating as a security-adjacent point rather than an ergonomic one. The untagged 422 above published CardPayment, BankTransferPayment and WalletPayment to any unauthenticated caller who sent a malformed body. Tagged unions publish your tag vocabulary instead, which is already part of the public contract.

Nested discriminated unions get hard to read. A union of unions with different discriminator fields validates correctly but produces error paths several segments deep. Flatten where you can, and give each level a distinctly-named tag.

If you also reshape the 422 envelope itself — grouping errors by field, say — see Customising Validation Error Responses, and note that a tagged union makes that reshaping far simpler because there is only ever one variant's worth of errors to group.

FAQ

What does Field(discriminator=...) actually change? It tells Pydantic to read one literal field first and validate against only the matching member, instead of trying every member and collecting all their failures. In the generated schema it emits oneOf plus a discriminator mapping rather than a bare anyOf.

Why is my 422 full of errors for variants I never sent? Because an untagged union has no way to know which member you meant, so it validates against all of them and reports every failure. In a real run a bad card payload against a three-member untagged union produced eight errors, six of which were about bank transfers and wallets.

What does the discriminator field have to be? A Literal-typed field present on every member of the union, with a distinct value per member. It cannot be optional, cannot have a plain str annotation, and every member must spell the field name identically.

What error do I get for an unknown tag? A single union_tag_invalid error naming the tag you sent and listing every tag that would have been accepted, located at the union field itself rather than inside any member. It is the most actionable 422 in Pydantic.

Do discriminated unions help client code generation? Substantially. The discriminator mapping in the OpenAPI document tells a generator which concrete type corresponds to which tag value, so it can emit a proper tagged union with narrowing instead of an untyped any or a union the caller must inspect by hand.