Strings and integers are convenient ways to represent states, modes, result codes, and permissions. They are also easy to mistype, mix with unrelated values, or pass through an API without making their meaning obvious.
Python’s enum module lets a program give those values names and a controlled set of members. The benefit is not simply replacing constants with a class. A well-chosen enumeration defines the domain boundary: which values exist, how they compare, whether integer compatibility is intentional, and whether values may be combined.
This article focuses on the stable core of Enum, IntEnum, and Flag, with practical rules for choosing among them and for keeping serialization behavior explicit.
Start with Enum for distinct domain states
Use Enum when members represent a closed set of named alternatives:
from enum import Enum
class JobStatus(Enum):
PENDING = "pending"
RUNNING = "running"
DONE = "done"Each class attribute becomes an enumeration member. Its name is the Python identifier and its value is the assigned value:
print(JobStatus.RUNNING.name)
print(JobStatus.RUNNING.value)The outputs are RUNNING and running.
Members are singletons within the enumeration, so identity comparison is appropriate:
if status is JobStatus.DONE:
archive_result()An ordinary Enum member does not compare equal to its raw value:
assert JobStatus.RUNNING != "running"That separation is useful. It prevents a status from silently behaving like every other string that happens to contain the same text.
Convert boundary values explicitly
External systems usually provide primitive values rather than Python enum members. Convert those values at the boundary:
raw_status = "running"
status = JobStatus(raw_status)
assert status is JobStatus.RUNNINGCalling the enum class with a member value performs a lookup. If no member has that value, Python raises ValueError:
try:
JobStatus("paused")
except ValueError:
print("unsupported job status")This is a useful validation boundary for configuration, database fields, message payloads, and HTTP responses. The rest of the program can work with JobStatus instead of repeatedly checking arbitrary strings.
Do not catch ValueError and silently substitute a member unless the domain actually defines that fallback. An unknown value can indicate a newer producer, corrupted data, or a contract mismatch that deserves visibility.
Look up by name only when the input is a name
Value lookup and name lookup are different operations:
assert JobStatus("running") is JobStatus.RUNNING
assert JobStatus["RUNNING"] is JobStatus.RUNNINGThe bracket form uses the member name and raises KeyError when the name does not exist. The call form uses the member value and raises ValueError for an unknown value.
Choose the form that matches the external contract instead of converting names and values interchangeably.
Keep wire values stable and meaningful
When enum values cross a persistence or network boundary, treat them as part of that contract.
For example:
from enum import Enum
class PaymentState(Enum):
CREATED = "created"
AUTHORIZED = "authorized"
CAPTURED = "captured"A database row or JSON payload can store PaymentState.CAPTURED.value, while application code uses the member itself.
Avoid persisting member.name merely because it is convenient unless the name is intentionally the external representation. Python identifiers are often renamed during refactoring; a deliberately chosen value can remain stable even when internal naming changes.
Likewise, do not persist the position of a member in the source file. Enumeration declaration order is not a durable business identifier.
Use auto when the numeric value is internal
Sometimes the specific value has no external meaning. auto() asks the enum machinery to generate one:
from enum import Enum, auto
class ParserState(Enum):
START = auto()
HEADER = auto()
BODY = auto()
FINISHED = auto()This communicates that code should care about member identity, not about a hand-assigned number.
Generated values should generally stay internal. If a protocol, file format, or database schema specifies exact numeric codes, write those codes explicitly so the contract is visible and reviewable.
Choose IntEnum only for intentional integer compatibility
IntEnum members are also integers. This is useful when an existing interface genuinely requires integer behavior:
from enum import IntEnum
class ExitCode(IntEnum):
OK = 0
TEMPORARY_FAILURE = 75
assert ExitCode.OK == 0That compatibility is also the main trade-off. A member can compare equal to an ordinary integer, so some of the type separation provided by Enum is lost.
Prefer plain Enum for new domain models where integer interchangeability is unnecessary. Choose IntEnum when compatibility with integer constants is itself a requirement, such as adapting an existing numeric API.
Do not rely on IntEnum to validate every integer operation
Because IntEnum is an int subclass, ordinary integer operations can produce plain integers rather than enum members:
result = ExitCode.TEMPORARY_FAILURE + 1
assert type(result) is intAn enum does not turn arbitrary arithmetic into domain-safe arithmetic. If the values are codes rather than quantities, avoid calculations that imply numeric meaning they do not have.
Use Flag for combinable options
Some domains do not describe one state from a closed set. They describe a combination of independent options. Flag models that case:
from enum import Flag, auto
class Permission(Flag):
READ = auto()
WRITE = auto()
EXECUTE = auto()Members can be combined with bitwise operators:
access = Permission.READ | Permission.WRITE
if Permission.WRITE in access:
print("writes are allowed")Intersection tests are also explicit:
if access & Permission.READ:
print("read capability is present")Use Flag when combinations are meaningful. Do not use it for mutually exclusive lifecycle states such as PENDING, RUNNING, and DONE; an ordinary Enum communicates that model better.
Prefer IntFlag only when integer bitmasks must interoperate
IntFlag combines flag behavior with integer compatibility. It is appropriate when code must interoperate with an API that consumes or returns numeric bitmasks.
As with IntEnum, integer compatibility weakens the boundary between the domain type and raw numbers. For application-internal flags, plain Flag usually communicates intent more strictly.
Understand aliases before depending on iteration
Python permits multiple names to have the same enum value unless aliases are prohibited:
from enum import Enum
class HttpMethod(Enum):
GET = "GET"
RETRIEVE = "GET"RETRIEVE is an alias of GET:
assert HttpMethod.RETRIEVE is HttpMethod.GETNormal iteration yields canonical members and does not yield aliases separately:
print(list(HttpMethod))If duplicate values would indicate a bug, make that invariant explicit with @unique:
from enum import Enum, unique
@unique
class Direction(Enum):
NORTH = 1
SOUTH = 2Adding another member with value 1 would then raise ValueError when the class is created.
Aliases can be useful for compatibility names, but they should be intentional. Otherwise they can hide accidental duplicate values and make member enumeration surprising.
Iterate members and inspect mappings deliberately
Iteration is useful for menus, validation metadata, and generated documentation:
for status in JobStatus:
print(status.name, status.value)When aliases matter, the __members__ mapping contains every declared member name, including aliases:
for name, member in HttpMethod.__members__.items():
print(name, member)Do not reach into undocumented implementation attributes to discover members. Iteration and __members__ are the supported interfaces for these two distinct views.
Keep serialization explicit
Many serializers do not automatically know which representation your application wants for an enum. Make the boundary explicit:
def job_to_dict(job_id: str, status: JobStatus) -> dict[str, str]:
return {
"id": job_id,
"status": status.value,
}And parse the value explicitly on input:
def status_from_dict(data: dict[str, str]) -> JobStatus:
return JobStatus(data["status"])This avoids coupling storage or wire behavior to a framework’s default enum encoding. It also makes migrations easier to reason about because the persisted representation is visible in application code.
If an external contract allows unknown future values, a closed enum may need an explicit compatibility strategy at that boundary. Do not pretend an unknown value is a known member; preserve or reject it according to the protocol’s requirements.
Add behavior when it belongs to the domain
Enum classes can define methods. This can keep behavior next to the state it describes:
from enum import Enum
class DeploymentState(Enum):
QUEUED = "queued"
RUNNING = "running"
SUCCEEDED = "succeeded"
FAILED = "failed"
def is_terminal(self) -> bool:
return self in {self.SUCCEEDED, self.FAILED}Callers can then ask the domain question directly:
if state.is_terminal():
release_resources()Keep such methods focused on behavior inherent to the enum. Database access, network calls, and large workflow orchestration usually belong elsewhere; putting them on enum members can turn a small domain type into an unexpected service object.
Do not confuse enums with state machines
An enum can define the allowed states, but it does not by itself define valid transitions.
Suppose a job may move from PENDING to RUNNING, then to DONE, but must never move from DONE back to RUNNING. The enum names the states; separate transition logic must enforce the workflow:
ALLOWED_TRANSITIONS = {
JobStatus.PENDING: {JobStatus.RUNNING},
JobStatus.RUNNING: {JobStatus.DONE},
JobStatus.DONE: set(),
}
def can_transition(current: JobStatus, target: JobStatus) -> bool:
return target in ALLOWED_TRANSITIONS[current]This separation keeps two concerns clear: the enum defines valid individual values, while the transition table defines valid relationships between them.
For more complex workflows, a dedicated state-machine abstraction may be easier to test and evolve than adding increasingly elaborate methods to the enum.
Treat enum values as migration-sensitive data
Changing an enum member is trivial in source code but can be a data migration when values are persisted.
Consider these changes separately:
- renaming a Python member while keeping its stored value;
- changing the stored value;
- removing a value that historical records still contain;
- adding a value that older consumers do not recognize.
The first can be an internal refactor if external code does not depend on the member name. The others can change a database or protocol contract.
Before deleting or changing a persisted value, identify existing records and downstream consumers. Enums make the allowed set visible, but they do not migrate stored data automatically.
Common pitfalls
Comparing a plain Enum to its raw value
JobStatus.RUNNING is not equal to "running". Convert at the boundary or compare .value only when raw-value comparison is intentionally required.
Choosing IntEnum for convenience
Integer compatibility is a semantic choice, not just shorter conversion code. Use it only when the domain must interoperate as integers.
Treating Flag as a lifecycle enum
Flags describe combinations. Mutually exclusive states are clearer as ordinary enum members.
Persisting generated values
Values from auto() are best treated as implementation details unless you deliberately define and freeze their external meaning.
Ignoring aliases
Duplicate values create aliases by default. Use @unique when duplicates should be rejected.
Assuming an enum enforces transitions
It constrains the set of states, not the legal movement between states. Validate transitions separately.
Make invalid values harder to express
Enums are most valuable at boundaries between raw data and domain logic. Convert strings or integers once, then pass named members through the core of the program. Use plain Enum for distinct states, Flag for combinations, and the integer-compatible variants only when interoperability requires them.
Keep external values deliberate, handle aliases consciously, and treat persisted enum values as contract data. With those rules, enum provides more than named constants: it gives the codebase a small, explicit vocabulary for values that should not be arbitrary.