Many programs represent a small fixed set of states with plain strings:
status = "paid"That looks simple, but the string carries no built-in guarantee that it belongs to the set of states your application actually supports. A typo such as "paied" is still a valid Python string. So is an unexpected value received from a file, database, message, or HTTP request.
When the valid choices are finite and meaningful, Enum gives those choices names and a type.
The useful mental model is:
external value
|
validate once
v
Enum member
|
application logicInstead of repeatedly asking whether arbitrary strings are valid, convert data at the boundary. Inside the application, work with known members such as OrderStatus.PAID.
This article shows how to use that pattern without turning every constant into an enum or hiding important serialization decisions.
Start with the problem: strings do not define a closed set
Suppose an order can be in exactly four states:
status = "pending"
status = "paid"
status = "shipped"
status = "cancelled"A function might compare those strings directly:
def can_ship(status):
return status == "paid"The function also accepts values that were never intended:
can_ship("PAID")
can_ship("payment-complete")
can_ship("paied")Those calls do not necessarily fail. They simply produce behavior based on arbitrary text.
You can add validation everywhere, but then the same list of accepted values tends to spread across the codebase. That creates another problem: different parts of the program can drift into different definitions of what is valid.
An enum centralizes that finite vocabulary.
Define the states as Enum members
Python’s standard library provides Enum:
from enum import Enum
class OrderStatus(Enum):
PENDING = "pending"
PAID = "paid"
SHIPPED = "shipped"
CANCELLED = "cancelled"Each member has a symbolic name and a value:
print(OrderStatus.PAID.name) # PAID
print(OrderStatus.PAID.value) # paidThe distinction is useful.
PAID is the Python-facing symbolic name. "paid" is the value you may choose to store or exchange with another system.
Application logic can now accept the enum member:
def can_ship(status):
return status is OrderStatus.PAIDA caller using the intended type is explicit:
can_ship(OrderStatus.PAID)The expression communicates more than the raw string "paid" because it identifies both the concept and the specific state.
Convert external values at the boundary
Enums become most useful when validation happens before data reaches business logic.
Suppose a JSON payload contains:
status = "paid"Convert the raw value to an enum member:
status = OrderStatus("paid")Calling the enum class with a value performs a value lookup. If the value exists, Python returns the corresponding member:
assert OrderStatus("paid") is OrderStatus.PAIDIf it does not exist, the lookup raises ValueError:
OrderStatus("unknown")That gives you a natural validation boundary:
def parse_order_status(raw_status):
try:
return OrderStatus(raw_status)
except ValueError as exc:
raise ValueError(f"unsupported order status: {raw_status!r}") from excThe important design decision is where this conversion occurs.
Prefer:
request / file / database row
|
v
parse and validate
|
v
OrderStatus
|
v
application logicover passing raw strings through many layers and validating them repeatedly.
Name lookup is different from value lookup
Python also supports lookup by member name:
OrderStatus["PAID"]That returns OrderStatus.PAID.
The two forms serve different inputs:
OrderStatus("paid") # lookup by value
OrderStatus["PAID"] # lookup by nameDo not interchange them accidentally. If an external protocol sends "paid", value lookup is usually the relevant operation when your enum values are defined as lowercase protocol strings.
Keep business logic on the enum side of the boundary
Once a value has been validated, avoid immediately converting it back to a string for internal decisions.
Prefer:
def next_action(status):
if status is OrderStatus.PENDING:
return "await payment"
if status is OrderStatus.PAID:
return "prepare shipment"
if status is OrderStatus.SHIPPED:
return "track delivery"
if status is OrderStatus.CANCELLED:
return "stop processing"
raise AssertionError(f"unhandled status: {status!r}")over:
def next_action(status):
if status.value == "pending":
...The first form uses the enum as the application’s vocabulary. The second recreates string comparisons inside the program and gives up much of the benefit.
Access .value when you need the external representation, not as the default way to reason about the state.
Serialize deliberately
Enums do not remove serialization decisions. They make those decisions visible.
If an API or stored record expects the lowercase value, serialize it explicitly:
payload = {
"status": OrderStatus.PAID.value,
}That produces a plain value suitable for formats such as JSON:
{"status": "paid"}When reading the data back, reconstruct the enum:
status = OrderStatus(payload["status"])This round trip gives the boundary a clear contract:
Enum member --serialize--> external value
Enum member <--parse------- external valueDo not assume every serializer automatically knows how you want an enum represented. Different frameworks and libraries can make different choices, and some require explicit conversion or configuration.
For portable application code, treat serialization as a boundary concern and test the representation your system actually emits.
Choose stable values when they cross system boundaries
The member name and member value serve different audiences.
For example:
class OrderStatus(Enum):
PAYMENT_RECEIVED = "paid"Renaming the Python symbol later:
class OrderStatus(Enum):
PAID = "paid"can preserve the external value "paid".
That can be useful when the value is stored in a database or exchanged through an API. The internal name can improve while the external contract remains stable.
The reverse is also true: changing a member’s value can be a compatibility change if persisted or remote data depends on it.
Therefore, when enum values cross process or storage boundaries, treat them as part of that boundary’s data contract.
Use unique values unless aliases are intentional
By default, Python enums allow multiple names to refer to the same value:
from enum import Enum
class DeploymentState(Enum):
READY = "ready"
STARTED = "ready"STARTED becomes an alias of READY:
assert DeploymentState.STARTED is DeploymentState.READYAliases can be useful during migrations or when several legacy names intentionally represent the same state. They can also hide accidental duplicate values.
When every member should have a distinct value, use @unique:
from enum import Enum, unique
@unique
class OrderStatus(Enum):
PENDING = "pending"
PAID = "paid"
SHIPPED = "shipped"
CANCELLED = "cancelled"If two names receive the same value, class creation fails with ValueError.
That moves a category of mistakes from runtime behavior to import-time validation.
Iteration skips aliases
Aliases have another subtle effect: normal iteration returns canonical members, not alias names.
Given:
class DeploymentState(Enum):
READY = "ready"
STARTED = "ready"this:
list(DeploymentState)contains only DeploymentState.READY.
If you intentionally use aliases and need to inspect every declared name, the class-level __members__ mapping includes aliases.
That distinction matters for generated documentation, migration tooling, or validation code that needs the declared names rather than only canonical members.
Do not use an enum when the set is not really closed
An enum works best when the domain has a finite set of meaningful choices controlled by the application or by a stable protocol.
Good candidates include:
- lifecycle states such as pending, active, and archived;
- supported output formats;
- fixed processing modes;
- protocol constants;
- roles or categories with a deliberately closed vocabulary.
An enum is a poor fit for data that naturally expands without code changes.
For example, these are usually data, not enum members:
customer names
country-specific product SKUs
user-created tags
database record identifiers
third-party event names that can appear dynamicallyIf adding a valid value should happen through configuration or data rather than a code deployment, an enum may make the system unnecessarily rigid.
Do not confuse states with transitions
An enum describes the available states. It does not by itself define which transitions are legal.
For an order, these members:
class OrderStatus(Enum):
PENDING = "pending"
PAID = "paid"
SHIPPED = "shipped"
CANCELLED = "cancelled"do not prevent code from moving directly from PENDING to SHIPPED.
If transition rules matter, model them separately:
ALLOWED_TRANSITIONS = {
OrderStatus.PENDING: {
OrderStatus.PAID,
OrderStatus.CANCELLED,
},
OrderStatus.PAID: {
OrderStatus.SHIPPED,
OrderStatus.CANCELLED,
},
OrderStatus.SHIPPED: set(),
OrderStatus.CANCELLED: set(),
}
def can_transition(current, target):
return target in ALLOWED_TRANSITIONS[current]Now the responsibilities are distinct:
Enum
-> which states exist
transition rules
-> which state changes are allowedThis separation keeps the enum simple and makes workflow rules easier to test.
Be careful when compatibility requires primitive behavior
Enum members are not plain strings or integers merely because their values are strings or integers.
That is usually a feature: it prevents unrelated values from comparing as if they were the same domain concept.
However, some interfaces genuinely require primitive-type compatibility. Python provides specialized enum classes for such interoperability, including integer-oriented variants, and newer Python versions also provide a string-oriented variant.
Do not choose those variants merely to avoid writing .value. Primitive-compatible enums weaken some separation between the enum domain and the underlying primitive type.
Start with Enum when you want a distinct domain type. Use a specialized variant when interoperability requirements justify it, and verify the behavior against the Python versions your application supports.
Avoid enums that become miniature classes
Enums can define methods, but that does not mean every behavior related to a state belongs inside the enum.
A small derived property can be reasonable:
class OrderStatus(Enum):
PENDING = "pending"
PAID = "paid"
SHIPPED = "shipped"
CANCELLED = "cancelled"
def is_terminal(self):
return self in {
OrderStatus.SHIPPED,
OrderStatus.CANCELLED,
}But large workflows, persistence logic, network calls, and cross-entity business rules usually belong elsewhere.
If an enum starts accumulating many dependencies and side effects, it is no longer just representing a finite vocabulary. Move orchestration into an application service or another component with clearer responsibilities.
Test the contract, not just the happy path
Useful enum tests focus on the behavior that matters at boundaries.
For example:
def test_known_status_is_parsed():
assert OrderStatus("paid") is OrderStatus.PAID
def test_unknown_status_is_rejected():
try:
OrderStatus("unknown")
except ValueError:
pass
else:
raise AssertionError("expected ValueError")If values are serialized externally, test that representation too:
def test_paid_status_serializes_as_stable_value():
assert OrderStatus.PAID.value == "paid"These tests protect the contract callers and stored data depend on.
Testing every enum member mechanically may add little value if the class declaration itself is already obvious. Focus tests on parsing, serialization, aliases, transition rules, or compatibility behavior that could break consumers.
A practical design checklist
Before introducing an enum, ask:
- Is the set of values intentionally finite?
- Should unknown values be rejected at a boundary?
- Do the states have domain meaning beyond their raw strings or numbers?
- Are the external values part of a storage or API contract?
- Are duplicate values intentional?
- Are state-transition rules separate from the list of states?
- Would configuration or data be more appropriate than code for adding new values?
If the answers point toward a closed domain vocabulary, Enum is often a good fit.
Conclusion
Python’s Enum is most useful as a boundary between arbitrary external values and a finite internal vocabulary.
Define meaningful members, validate raw values when they enter the application, use enum members throughout business logic, and serialize .value deliberately when data leaves the boundary. Use @unique when aliases are not part of the design, and keep transition rules separate from the list of possible states.
The goal is not to replace every string constant. It is to make genuinely finite domains explicit so invalid values are rejected early and the rest of the code can reason in terms of known states.