A field that could be one of several shapes - and how to stop Pydantic guessing which one you meant.
Overview
The problem a union creates
Sometimes a field can hold more than one kind of value. A payment could be a card or a bank transfer. A message could be text or an image. In Python typing you write that as a union, and Pydantic has to decide which member of the union the data belongs to.
That decision is harder than it sounds. Pydantic tries the members and picks the best match, but when two members look alike, it can choose the wrong one without telling you. With unions of plain types the result is usually fine. With unions of models it can go badly, and the error messages get long and confusing.
This article starts with the problem a union creates. It explains how smart mode picks a member, and the places where order still decides the outcome. It then shows why unions of models are worse, and what the error looks like when every member fails. The fix is a tag. A discriminated union uses one field, such as a type name, to say which model applies, so Pydantic no longer guesses. The result is faster, clearer, and far easier to debug.
You will meet unions sooner than you expect. Any API that accepts more than one kind of thing needs one. Events, payments, notifications and search results all fall into that group. The trouble is that a union that works in a quick test can misbehave on real traffic. The wrong member wins, or the error report becomes a wall of text that hides the real problem. This article shows you both of those failures before it shows the cure, so you can recognise them in your own code. The cure is small, and once you have used it you will reach for it by default. It also makes the generated documentation much clearer for anyone who calls your API.
Once you see the failure, the fix will feel obvious and easy to remember.
Union[A, B] says a value may be either. What it doesn't say is how to decide which, and a thing has to decide.
For simple types the choice is often obvious and often right. For models it's often neither. The failure mode is the worst kind. It doesn't raise, it just gives you the wrong object.
Worth knowing
Smart mode tries an exact type match across all members first, and only then attempts conversion left to right.
Where no member matches exactly, the order you wrote members in decides the result. That is a fragile thing to depend on.
A union of models is ambiguous whenever more than one of them can accept the same payload — which is common once fields have defaults.
Field(discriminator="kind") with a Literal tag on each model removes the guessing entirely.
Discriminated unions produce one error from the right branch instead of a pile of errors from every branch that was tried.
Discriminated unions are also faster: one lookup on the tag instead of attempting each member in turn.
Unions and Discriminated Unions: Stop Pydantic Guessing
A field that could be a few shapes. How to make the choice clear.
Smart mode: exact match first
A union tries an exact type match before it tries converting. That resolves the obvious cases and leaves the ambiguous ones to order.
example_01.pyPydantic
from typing import Union
from pydantic import BaseModel
class M(BaseModel):
v: Union[int, str]
for value in [9, "nine", "9", 9.0, True]:
got = M(v=value).v
print("%-8r -> %-8r (%s)" % (value, got, type(got).__name__))
# 9 stays an int and "nine" stays a str because both match exactly.
# "9" is where it gets interesting: it matches str exactly, so str wins,
# even though int is written first.
Output
Where order does change the answer
When no member matches exactly, the union converts left to right. The same input can then produce different types depending on how you wrote it.
example_02.pyPydantic
from typing import Union
from pydantic import BaseModel
class A(BaseModel):
v: Union[int, float]
class B(BaseModel):
v: Union[float, int]
for value in ["7"]:
a, b = A(v=value).v, B(v=value).v
print("int|float gives %-6r (%s)" % (a, type(a).__name__))
print("float|int gives %-6r (%s)" % (b, type(b).__name__))
print()
print("Same input, two annotations, two types. If that distinction")
print("matters to your program, do not express it as a plain Union.")
Output
Unions of models get ambiguous fast
Two models with overlapping fields are a coin toss. Pydantic will pick the first that validates, which may not be the one the data meant.
example_03.pyPydantic
from typing import Union
from pydantic import BaseModel
class Video(BaseModel):
title: str
seconds: int = 0
class Quiz(BaseModel):
title: str
questions: int = 0
class Item(BaseModel):
content: Union[Video, Quiz]
# Unambiguous - only Quiz has 'questions':
print(Item(content={"title": "Vectors", "questions": 5}).content)
# Ambiguous - both models accept a bare title, and Video is written first:
guess = Item(content={"title": "Vectors"}).content
print(guess, "<- got", type(guess).__name__, "by position, not by meaning")
Output
A discriminator makes it explicit
Give each model a literal tag field and point Field(discriminator=) at it. Pydantic then reads the tag and goes straight to the right model.
example_04.pyPydantic
from typing import Literal, Union
from pydantic import BaseModel, Field
class Video(BaseModel):
kind: Literal["video"]
title: str
seconds: int
class Quiz(BaseModel):
kind: Literal["quiz"]
title: str
questions: int
class Item(BaseModel):
content: Union[Video, Quiz] = Field(discriminator="kind")
print(Item(content={"kind": "video", "title": "Vectors", "seconds": 240}).content)
print(Item(content={"kind": "quiz", "title": "Check", "questions": 4}).content)
# No guessing left: the tag decides.
Output
Better errors, too
Without a discriminator a failure reports every branch it tried. With one, it reports the single branch that was actually meant.
example_05.pyPydantic
from typing import Literal, Union
from pydantic import BaseModel, Field, ValidationError
class Video(BaseModel):
kind: Literal["video"]
seconds: int
class Quiz(BaseModel):
kind: Literal["quiz"]
questions: int
class Plain(BaseModel):
content: Union[Video, Quiz]
class Tagged(BaseModel):
content: Union[Video, Quiz] = Field(discriminator="kind")
bad = {"content": {"kind": "video", "seconds": "lots"}}
try:
Plain.model_validate(bad)
except ValidationError as e:
print("without discriminator:", e.error_count(), "errors")
for err in e.errors():
print(" ", err["loc"], err["type"])
try:
Tagged.model_validate(bad)
except ValidationError as e:
print()
print("with discriminator :", e.error_count(), "error")
for err in e.errors():
print(" ", err["loc"], err["type"])
Output
An unknown tag fails cleanly
The discriminator itself is validated. A tag nobody declared produces one clear error naming the values that are allowed.
example_06.pyPydantic
from typing import Literal, Union, List
from pydantic import BaseModel, Field, ValidationError
class Video(BaseModel):
kind: Literal["video"]
seconds: int
class Quiz(BaseModel):
kind: Literal["quiz"]
questions: int
class Track(BaseModel):
items: List[Union[Video, Quiz]] = Field(discriminator="kind")
t = Track(items=[{"kind": "video", "seconds": 240},
{"kind": "quiz", "questions": 4}])
for item in t.items:
print(type(item).__name__, "->", item.model_dump())
try:
Track(items=[{"kind": "podcast", "seconds": 10}])
except ValidationError as e:
err = e.errors()[0]
print()
print("type :", err["type"])
print("said :", err["msg"])
Output
Smart mode, and what it actually does
Pydantic v2 doesn't simply try members left to right. It uses smart mode. This Is a two-pass plan.
First it looks for a member the value already matches just, without any conversion. An int input against Union[int, str] matches int. A str input matches str. No ambiguity, no order dependence.
Only if nothing matches just does it try conversion, left to right, taking the first that succeeds.
This resolves most everyday unions sensibly. It's a genuine gain over v1. This Was strictly left-to-right and would happily turn an int into a string because str was written first.
But it doesn't drop ambiguity. It relocates it to the cases where nothing matches just. Which, for data arriving from JSON as strings, is a lot of cases.
Where order still decides
Union[int, float] given the string "7" makes an int. Union[float, int] given the same string makes a float. Neither member matched just. Conversion ran left to right and the first success won.
If the gap matters to your program. And the gap between 7 and 7.0 matters in more places than people expect, from JSON output to dict keys to equality checks. Then you've a action that depends on the order somebody wrote two words in. That will live just until somebody reorders them for tidiness.
The lesson isn't to store the rules. It's that a plain union of convertible types is an unstable way to express a real gap.
Unions of models are worse
With models the ambiguity is structural.
class Video(BaseModel):
title: str
seconds: int = 0
class Quiz(BaseModel):
title: str
questions: int = 0
Both accept {"title": "Vectors"}, because the other field has a default in each. Neither matches "just" in any real sense. The first that validates wins. And that is Video, purely because of where it appears in the annotation.
The result is a Video that was meant to be a Quiz, made without any error. Found one spot much later when questions is missing.
Defaults make this sharply more likely. This Is worth noticing. Adding a default to a field of one union member can change how *other* payloads are classified.
The fix is a tag
A discriminated union asks the data to say which shape it's. Each member gets a Literal field with a distinct value, and the union names it:
class Video(BaseModel):
kind: Literal["video"]
title: str
seconds: int
class Quiz(BaseModel):
kind: Literal["quiz"]
title: str
questions: int
content: Union[Video, Quiz] = Field(discriminator="kind")
Now there's no guessing. Pydantic reads kind, looks up the matching model. Validates against that one only.
This is the same pattern as a tagged union in other languages. The same one you see in real API payloads all over. Stripe events, webhook bodies, message envelopes. If your data already has a type or kind field, it's already discriminated and you're just telling Pydantic about it.
Three things you get
Correctness. The tag decides, so the answer doesn't depend on annotation order, on which fields happen to have defaults, or on which member was added first.
Better errors. Without a critic, a failure means Pydantic tried each member and none worked. You get the errors from all of them. A pile of messages about branches you were never in. With a critic, it tried just one. Reports just that one's problem.
That gap is stark on a union with five members. It turns "here are fifteen errors, work out which three are yours" into "seconds should be a valid int".
Speed. One dict lookup on the tag, rather than attempting check against each member until a thing sticks.
When the tag is wrong
The critic field is checked too. A value that matches no member makes a single union_tag_invalid error listing the permitted tags. This Is an great error. It tells the caller both what was wrong and what the options are.
A payload missing the tag fully gives union_tag_not_found. Again, one clear error rather than a scatter.
Practical shapes
Discriminated unions compose. A List[Union[Video, Quiz]] with a critic validates each item by its own tag. This Is just what a feed of mixed content needs. Errors still carry the index.
The tag field is a real field. It appears in model_dump(), it's needed. It must be a Literal. A plain str won't do. The whole method depends on the value being known at class-meaning time.
For a union that grows over time, Union of many members is unwieldy to write. Annotated helps:
Now Content is a named type you can use anywhere. Adding a fourth member is one edit in one place.
Optional is a union too
Worth noticing, because it clarifies a thing people treat as special: Optional[X] is just Union[X, None]. All above applies to it.
It happens to be the least ambiguous union possible. None is only ever itself and nothing converts to it. That is why Optional never causes the problems this module shows. Why it's safe to reach for without thinking.
What to reach for
Use a plain union for truly simple cases where each member is a distinct, non-convertible type. Union[int, None], or a union of models with obviously disjoint needed fields.
Use a discriminated union for anything polymorphic: content types, event types, message kinds, shape variants. If you're modelling "one of these things", this is the tool.
Consider whether you want a union at all. at times the honest model is one type with optional fields. At times two split endpoints. A union in an API is a thing each client has to branch on. The tag makes that branching possible. But fewer branches is still better than more.
Left-to-right, when you actually want it
Smart mode is the default, and there's a second mode for the cases where you want the older action:
Now members are tried strictly in order and the first success wins, with no exact-match pass first. That is at times what you want. A deliberate preference order. Here You'd rather have an int if the value can possibly be one.
It's worth knowing this exists mainly so that you recognise the action when reading v1 code. This Worked this way always. A union in an old code may be leaning on order in a way that quietly changed meaning during the v2 migration.
The performance argument
There's a cost to a plain union that is easy to overlook.
checking against Union[A, B, C, D] may mean attempting up to four validations, each of which builds errors before failing. For a list of a thousand items, a union whose correct member is often last is doing four times the needed work and discarding three quarters of it.
A discriminated union does one dict lookup and one check. For large sets of polymorphic data. An event log, a feed, a batch of webhook payloads. That gap is measurable rather than theoretical.
So the tag isn't only about correctness and error quality. It's also the fast path.
Nullable unions and the shape of Optional
Optional[X] is Union[X, None], and it's the one union that never causes ambiguity. Nothing converts to None and None converts to nothing.
That is worth stating because it shows why Optional feels other from other unions though it isn't special. It isn't that Pydantic treats it differently. It's that its members can't share.
A related shape that does need care: Optional[Union[A, B]], or equivalently Union[A, B, None]. The None part is unambiguous. The A versus B part has all the problems given above. Adding a critic still works. None is handled apart from the tagged members. So a nullable discriminated union is a just right good thing to write.
Migrating a plain union to a tagged one
If you've an existing union that is misbehaving, the change is often additive rather than breaking.
Add a kind field with a Literal to each member and give it a default matching that member's tag:
class Video(BaseModel):
kind: Literal["video"] = "video"
Existing code that makes Video(...) without a tag keeps working. The default fills it in. Existing *data* without the tag will now fail check. This Is the part to plan for. Either a migration that adds the field, or a model_validator(mode="before") that infers the tag from the shape for a transition period.
The inference checker is a handy trick and a temporary one. It looks at which fields are present, decides what the payload must be. Writes the tag in. Keep it until the old data is gone, then delete it. It's just the guessing that the critic was added to drop.
Unions in the schema
A plain union turns anyOf in JSON Schema. A list of options with nothing to say how a consumer should choose between them. A made client will often give a type that could be any of them and leave the disambiguation to whoever calls it.
A discriminated union turns oneOf with a critic object naming the property and mapping its values to schemas. Tooling understands this. Made clients give a proper tagged type, docs groups the variants and shows which tag selects which. Checkers on the other side can check the same rule you check.
If your API is consumed by made clients, this alone is a strong reason to tag each polymorphic field. The gap between the two client types. One you've to narrow by inspection, one that narrows itself. Is felt by each consumer on each call.
A checklist
If the members are a fixed set of *kinds* of thing, tag them.
If the data already has a type, kind or event field, you've a tag. Tell Pydantic about it.
If the members are simple, disjoint scalar types, a plain union is fine.
If you're leaning on the order you wrote the members in, stop and add a tag. That dependency is invisible to the next reader.
And if you can't find a natural tag, consider whether the union is really modelling one thing with optional parts. This Is often what a hard-to-tag union turns out to be.
What the tag is really doing
A discriminated union works because it moves a choice from inference to declaration.
Without a tag, a thing has to work out what a payload is by looking at what it holds. That is guessing. Carefully built. Guessing has a failure mode where it's confidently wrong. With a tag, the payload states what it's and check checks that claim against one schema.
That is a pattern well beyond Pydantic. Any time a system decides what a thing is by checking its shape, adding an clear marker makes the system simpler, faster and more honest about its failures. The critic is the version of that idea you get for one line of annotation.
Before you reach for a union
One question worth asking first: is this really a few shapes, or one shape whose fields vary?
A union of two models that share nine of their ten fields is often the second thing wearing the costume of the first. One model with an optional field is simpler to write, simpler to consume and simpler to doc.
Reach for a union when the options are truly other. Other needed fields, other meaning, other handling downstream. When they're the same thing with a variation, model the variation.
Questions people ask about Unions and Discriminated Unions
How does Pydantic choose a member of a union? In smart mode, which is the default, it tries each member and prefers an exact type match, then the best partial match. It does not just take the first that works.
What is a discriminated union? A union of models that each carry a literal tag field, such as kind: Literal["video"]. Pydantic reads the tag and validates against that one model only.
How do I declare one? Write Annotated[Video | Article, Field(discriminator="kind")] as the field type.
Why use a discriminator? Errors become clearer, since only the chosen model's errors appear, and validation is faster because there is no trial and error.
What error do I get for an unknown tag? A union_tag_invalid error that lists the allowed tags.
Is Optional[X] a union? Yes, it is X | None. Pydantic tries X and then accepts None.
Where to go after Unions and Discriminated Unions
Unions choose between shapes. The next module is about choosing between *values*. Enums and literals. This Are how you say a field may only ever hold one of a small fixed set.
Check yourself
0 of 4
Answer without scrolling back up.
In smart mode, what does Pydantic try first for a union?
An exact match wins without any conversion. Only when nothing matches exactly does it fall back to converting left to right, which is where order starts to matter.
Two models in a union can both accept `{"title": "x"}`. What happens?
This is the dangerous case: no error, just the wrong object. Defaults make it much more likely, since they let a member accept payloads it was never meant for.
What does `Field(discriminator="kind")` require of each member?
The value must be known at class-definition time so Pydantic can build the tag-to-model lookup, which is what `Literal` provides and a plain `str` does not.
Why do discriminated unions produce better errors?
Without a tag, every member is tried and every member's failures are reported. With one, exactly one branch runs, so the report is about the shape the data actually claimed to be.
Cheat sheet
Unions and Discriminated Unions
Handle fields that accept multiple shapes with Union, and add a discriminator so Pydantic picks the right branch without guessing.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.