Closed sets of values, the two ways to spell them, and why a Literal usually beats a regular expression.
Overview
The rule a type cannot express
Many fields should accept only a small, fixed set of values. A status can be draft, published or archived. A level can be beginner, intermediate or advanced. A plain string type cannot express that, because any string would pass. You need a type that names the allowed values.
Python gives you two ways to do it. A Literal lists the exact values inline, right in the annotation. An Enum defines a named class with members, which suits values you use in many places. Pydantic validates both, rejects anything outside the set, and puts the allowed values into the error message and the JSON Schema.
This article explains the rule a plain type cannot express and why a regular expression is the wrong tool for it. It covers Literal first, since it is the lighter option. Then it moves to Enum, including how Pydantic converts between a raw value and a member. You will see why the str mixin matters for JSON, and why leaving it out causes trouble. The final section helps you choose between the two, and covers what happens when new values appear in the data later.
Most applications are full of closed sets. Roles, states, categories and plan names all qualify. Getting them wrong does not usually crash anything. It lets a typo travel deep into your system, where it turns up as a strange bug days later. A closed type stops that at the door. The article shows both options side by side, so you can see what each one costs and what each one gives back. It also covers how the choice affects the JSON your API sends, which is where many people first notice a problem. Take a few minutes to learn which one fits which job, and you will make the choice quickly every time after.
Both choices are quick to learn and easy to apply.
track: str allows each string ever written. Your use allows four. That gap is where invalid data lives. Closing it's one of the highest-value constraints on hand. A misspelt group is a bug that lives for months because nothing rejects it.
There are two good ways to close it, and one common bad one.
Worth knowing
Literal["a", "b"] is the shortest way to declare a closed set, and the error it produces names every allowed value.
A Literal becomes an enum in JSON Schema, so API docs and generated clients can render it as a choice. A pattern cannot.
Use an Enum when the set deserves a name, needs methods, or is referenced from code in several places.
Inherit from str as well as Enum so members compare equal to their strings and serialise as plain text.
model_dump() keeps enum members; model_dump(mode="json") and model_dump_json() convert them to their values.
Literal is what a discriminated union reads as its tag, which is the one place it cannot be replaced by anything else.
Enums and Literals: Closed Sets Done Properly
Two ways to say a field may only hold one of a fixed list, and when each is right.
Literal is the smallest way to say it
A Literal lists the permitted values inline. Anything else is refused, and the error names the options.
example_01.pyPydantic
from typing import Literal
from pydantic import BaseModel, ValidationError
class Module(BaseModel):
title: str
track: Literal["maths", "python", "dsa", "ml"]
print(Module(title="Vectors", track="maths"))
try:
Module(title="Vectors", track="astrology")
except ValidationError as e:
err = e.errors()[0]
print()
print("type :", err["type"])
print("said :", err["msg"])
print("got :", repr(err["input"]))
Output
Why not a regular expression
A pattern can express the same rule and tells you far less. Compare the two errors, and remember which one a client can render as a dropdown.
example_02.pyPydantic
from typing import Literal
from pydantic import BaseModel, Field, ValidationError
class WithPattern(BaseModel):
track: str = Field(pattern=r"^(maths|python|dsa|ml)$")
class WithLiteral(BaseModel):
track: Literal["maths", "python", "dsa", "ml"]
for cls in (WithPattern, WithLiteral):
try:
cls(track="astrology")
except ValidationError as e:
print("%-12s %s" % (cls.__name__, e.errors()[0]["msg"]))
print()
print("in the schema:")
print(" pattern:", WithPattern.model_json_schema()["properties"]["track"])
print(" literal:", WithLiteral.model_json_schema()["properties"]["track"])
Output
Field(pattern=r"^(maths|python|dsa|ml)$") enforces the same rule. It's worse in four distinct ways. They're worth listing because the pattern approach is common.
The error. A Literal says the input should be one of a list. Gives the list. A pattern says the string should match a regular formula. Prints the formula. One of those is usable by a person who didn't write your code.
The schema. A Literal turns enum in JSON Schema. docs renders it as a set of choices, a made client offers a type with four options, a form builder makes a dropdown. A pattern turns a pattern, and each tool downstream shrugs.
Static checking. Mypy knows the four values of a Literal and will tell you when a compare can never be true. It has no idea what a regular formula permits.
Maintenance. Adding a fifth track means editing a regular formula. This Is a place people make mistakes, versus adding a word to a list.
The only case for a pattern is a set that is truly open. A format rather than a list. Slugs, postcodes, identifiers. If you can enumerate the values, enumerate them.
Enum when the values need behaviour
An Enum gives the set a name, a home for methods, and members you can refer to in code rather than retyping the strings.
example_03.pyPydantic
from enum import Enum
from pydantic import BaseModel
class Track(str, Enum):
MATHS = "maths"
PYTHON = "python"
DSA = "dsa"
@property
def label(self) -> str:
return {"maths": "Maths for ML",
"python": "Python",
"dsa": "Algorithms"}[self.value]
class Module(BaseModel):
title: str
track: Track
m = Module(title="Vectors", track="maths") # the string is accepted
print("field :", m.track)
print("is enum :", isinstance(m.track, Track))
print("value :", m.track.value)
print("label :", m.track.label)
print()
print("by member:", Module(title="Norms", track=Track.DSA).track.label)
Output
str, Enum - and why the mixin matters
Inheriting from str makes members behave like strings everywhere else in your program, and keeps the JSON output plain.
example_04.pyPydantic
from enum import Enum
from pydantic import BaseModel
class Plain(Enum):
MATHS = "maths"
class Strish(str, Enum):
MATHS = "maths"
class A(BaseModel):
t: Plain
class B(BaseModel):
t: Strish
a, b = A(t="maths"), B(t="maths")
print("plain == 'maths' :", a.t == "maths")
print("str,Enum == 'maths' :", b.t == "maths")
print()
print("plain json :", a.model_dump_json())
print("str json :", b.model_dump_json())
print()
print("dump mode=python:", b.model_dump()["t"], type(b.model_dump()["t"]).__name__)
print("dump mode=json :", b.model_dump(mode="json")["t"])
Output
Literals in a discriminated union
This is where Literal stops being a convenience and becomes load-bearing: it is the tag a discriminated union reads.
example_05.pyPydantic
from typing import Literal, Union, List
from pydantic import BaseModel, Field
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("%-6s %s" % (type(item).__name__, item.model_dump()))
print()
print("the tag is a real field:", t.items[0].kind)
Output
Literals are not only strings
Any hashable literal works — numbers, booleans, None — and they combine, which is useful for versioned payloads.
example_06.pyPydantic
from typing import Literal, Optional
from pydantic import BaseModel, ValidationError
class Payload(BaseModel):
version: Literal[1, 2]
mode: Literal["fast", "exact"] = "fast"
debug: Literal[True, False, None] = None
print(Payload(version=2)) # the literal value itself
print(Payload(version=1, mode="exact", debug=True))
for bad in [3, "2"]: # note: "2" is NOT coerced
try:
Payload(version=bad)
except ValidationError as e:
print("version=%-4r %s" % (bad, e.errors()[0]["msg"]))
# A Literal matches the value as given. Unlike an int field, it does not
# parse "2" into 2 first - the literal members are compared directly.
Output
Literal
track: Literal["maths", "python", "dsa", "ml"]
The permitted values are written where the type goes. Anything else raises, with an error that lists the options.
It's the smallest thing that works, it needs no imports beyond typing. Mypy understands it. So your own code gets checked against the same set. A compare against "mathematics" is flagged by your editor before it ever runs.
Three things follow that a Literal doesn't give you.
A name to refer to.Track.MATHS instead of "maths" in the code that consumes the model. That means a typo is an AttributeError at import rather than a compare that quietly returns False.
One meaning. The set exists once and is used by each model that needs it. A Literal written out in four models is four copies. Though Annotated fixes that. A named Track = Literal[...] alias is a just right good middle ground.
one spot to put action. A display label, a colour, an sorting, a from_legacy_name classmethod. Enums can have methods and properties. That is often just where that logic belongs.
The str mixin is not optional
Write class Track(str, Enum), not class Track(Enum), unless you've a exact reason.
Without the mixin, members aren't strings. Track.MATHS == "maths" is False. This Breaks each compare in code that doesn't know about the enum. json.dumps refuses them. String formatting makes Track.MATHS rather than maths.
With the mixin, members *are* strings with extra structure. each compare works, output is plain text. Code that was written before the enum existed keeps working.
Python 3.11 added StrEnum. This Is the same idea with a cleaner spelling. Either is fine. The plain Enum is the one to avoid.
What comes out when you dump
This trips people up, so it's worth being clear.
model_dump() returns Python objects, so an enum field comes back as the enum member. That is often what you want inside your program.
model_dump(mode="json") and model_dump_json() convert members to their values. JSON has no enums. So the wire format is the plain string either way.
With the str mixin the gap rarely bites. The member behaves like its value anyway. Without it, a member reaching json.dumps raises. That is the shape of the bug.
Literals do more than strings
Any hashable literal works: Literal[1, 2], Literal[True], Literal[None], and mixtures.
Literal[1, 2] for a version field is a truly good pattern. It makes a payload's supported versions clear and rejects an unsupported one at the boundary with a clear message, rather than one spot deep in code written for version 1.
One thing to know, because it differs from a plain int field. A Literal checks the value as given rather than parsing it first. Literal[1, 2] given the string "2" raises literal_error. Here minutes: int would happily have converted it. If a version number arrives from a query string as text, annotate it int and constrain it, or convert before checking.
The one place Literal is required
A discriminated union reads its tag from a Literal field. Nothing else will do. Not a str with a default, not an Enum member as a default. Because the method needs the value known at class-meaning time to build the lookup table.
So if you're writing polymorphic models, Literal isn't a stylistic choice. It's the method.
Choosing
Reach for **Literal** when the set is small, local. Doesn't need action. A status field used by one model, a critic tag, a version number.
Reach for **Enum** when the set is part of your domain vocab, is used in a few places, or wants methods. Anything you'd find yourself writing a constants module for.
Reach for a **named Literal alias**. Track = Literal["maths", "python"]. When you want reuse without the ceremony of a class. It's underused and it's often just right.
And avoid a bare str with a comment listing the allowed values. That comment is a schema that nothing enforces. It will be wrong within a year.
Migration, briefly
One hands-on warning. Adding a value to a Literal or Enum is safe. Dropping one is a breaking change for anyone who has stored the old value. Check will start rejecting data that was before fine. Including rows already in your DB.
The usual fix is to keep the old value accepted, mapped to a thing sensible in a checker, for if old data exists. It's worth thinking about before you tighten a set that has been open for a while.
Named literal aliases
There's a middle option between an inline Literal and a full Enum that deserves more use than it gets:
Track = Literal["maths", "python", "dsa", "ml"]
class Module(BaseModel):
track: Track
class Lesson(BaseModel):
track: Track
One meaning, reused, with no class to write and no .value to note. Mypy narrows it in your own code. The schema still gets a proper enumeration.
It's the right answer oddly often. The reason to reach past it for a real Enum is action. A label, an sorting, a lookup. Or the ergonomics of Track.MATHS over the bare string. If you need neither, the alias is less machinery for the same result.
Enums in the schema
Both spellings give an enum in JSON Schema. But They differ in one handy way.
A Literal inlines the values into the field. An Enum makes a named meaning in $defs that the field links. A set used by six fields appears once and is referenced six times.
That matters for made clients. A referenced meaning often turns a named type in the target language. A TypeScript union alias, a Java enum. Which is reusable on the consumer's side too. Inlined values become six anonymous unions that happen to have the same members.
So for a set that appears in more than a couple of places in a public API, an Enum gives your consumers a better artefact, not just you.
Defaults and the two spellings
A small ergonomic gap worth knowing.
With a Literal, the default is the value: track: Track = "maths".
With an Enum, the default is most times the member: track: Track = Track.MATHS. The string also works. Check coerces it. The member reads better and is checked by your editor.
One caution with enum defaults: = Track.MATHS is checked once at class-meaning time. This Is fine because enum members are singletons and immutable. This is one of the few mutable-looking defaults that is truly safe.
Extending a set safely
Adding a value is backwards matching for the producer and not always for the consumer.
Your model will accept the new value at once. each client that has made a type from your schema won't, until they regenerate. And a strict client may reject a response holding a tag it has never heard of.
This is a real API design consideration rather than a Pydantic one. The usual mitigations are. Version the endpoint, doc that the set is open to extension so clients build tolerant parsers, or add the new value behind a flag until consumers have caught up.
Dropping a value is worse and worth stating plainly. It will start rejecting data you've already stored. If a track column holds "legacy" and you drop it from the enum, each read of those rows now raises. The safe path is to keep accepting the old value, map it in a checker. Only drop it once the data is gone.
Where an enum is the wrong shape
Two cases where reaching for an enum causes more trouble than it blocks.
A set that truly changes at runtime. groups a user can create, tags from a DB, anything editable through an admin interface. An enum is fixed at import. A set that changes needs a checker that checks against the current list. That check belongs in the layer that can see the list.
A set with hundreds of members. Country codes, currency codes, timezone names. Strictly an enum works. In practice the schema turns huge, the made client turns huge. The error message lists three hundred options. A pattern plus a lookup is kinder to everybody.
The rule of thumb: enumerate when the set is small, stable and part of your domain's vocab. else validate membership another way.
Enums and Literals: summary
Literal for small, local sets and for critic tags. A named Literal alias when the same small set is used in a few places. Enum when the set is domain vocab, needs action, or appears across a public API where consumers gain from a named type. str, Enum always, never a bare Enum. And never a bare str with a comment.
The underlying point
A closed set is one of the few pieces of domain knowledge that a type system can hold fully.
Most rules are guesses — a length bound, a range, a pattern that permits things you'd reject. "This field is one of these four values" is exact. There's nothing left over, no edge case, no judgement call. Writing it down as a Literal or an Enum captures the entire truth about that field.
That is why it's worth the small effort of not writing str. You get an exact error, a schema a client can render, a static check on your own checks. A meaning that can't drift from the code that uses it. All from choosing a more exact annotation than the one that would have worked.
A last practical note
When you add an enum to an existing field, run your data through it before you deploy.
A str field that has been accepting anything for a year almost certainly holds values no one expected. A trailing space, a other case, an old name from before a rename, a placeholder somebody typed once. each one of those will start raising the moment the annotation tightens.
Finding them beforehand is a short script and an afternoon. Finding them later is an incident. The failures arrive on reads of existing data rather than on new input. This Is the direction no one tests.
Questions people ask about Enums and Literals
When should I use Literal and when an Enum? Use Literal for a short, fixed set of values that appear only in this one place. Use an Enum when the set has a name, is shared across models, or needs methods.
Why does my enum dump as <Level.easy: 'easy'>? A plain Enum dumps as the member in Python mode. Inherit from str (class Level(str, Enum)) or call model_dump(mode="json") to get the plain value.
How do I accept enum values case-insensitively? Add a mode="before" field validator that lowercases the input, or define _missing_ on the enum. Pydantic itself matches the value exactly.
What error do I get for a value outside the set? A literal_error for Literal and an enum error for enums, each listing the permitted values in the message.
Can I use Literal with non-string values? Yes. Integers, booleans, None and members of an enum all work, for example Literal[1, 2, 3].
How do I add a new value without breaking old data? Adding a member only widens what is accepted, so old data still validates. Removing one is the breaking change, because stored values may no longer parse.
Where to go after Enums and Literals
The next module covers the types Pydantic already knows how to parse for you. Dates, times, UUIDs and decimals. And the traps in each, above all the two that cost money: floats and timezones.
Check yourself
0 of 4
Answer without scrolling back up.
Why prefer `Literal["a", "b"]` over `Field(pattern=r"^(a|b)$")`?
The Literal error lists the allowed values, the schema becomes a renderable choice for clients and docs, and static checkers can verify your own comparisons against it.
Why write `class Track(str, Enum)` rather than `class Track(Enum)`?
Without the mixin, `Track.MATHS == "maths"` is False and json.dumps refuses the member, which breaks any code that does not know the enum exists.
What does `model_dump()` return for an enum field, versus `model_dump_json()`?
`model_dump()` keeps Python objects, so you get the member. JSON has no enums, so the JSON forms convert to the underlying value.
Where is `Literal` not just a preference but the required mechanism?
The discriminator lookup is built at class-definition time, so the tag value must be known then - which is what Literal provides and a plain str does not.
Cheat sheet
Enums and Literals
Restrict a field to a fixed set of values using Enum or Literal, and see when each approach fits your data model best.
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.