Every codebase eventually needs a closed set of named values ā order statuses, log levels, directions. The enum module, in the standard library since 3.4 and steadily improved since, gives you a type-safe, autocomplete-friendly way to define them instead of scattering string literals or bare integers everywhere.
1Why Enums Beat Magic Strings and Loose Constants
Before enum, a closed set of states was usually represented as either raw string literals ("pending", "shipped") scattered across the codebase, or module-level integer constants (PENDING = 1). Both approaches share the same weakness: nothing stops you from passing an unrelated string or int where a status was expected, and a typo like "Pending" fails silently ā it's just a different string, not an error, until some comparison mysteriously never matches.
class OrderStatus(Enum): groups related constants under one namespace, gives each a distinct identity (OrderStatus.PENDING is not equal to the string "pending" ā it's an object with a .name and a .value), and makes every valid state discoverable via autocomplete the moment you type OrderStatus.. A static type checker treats a parameter annotated status: OrderStatus as accepting only genuine OrderStatus members, catching an accidental "pending" string argument as a type error before the code runs.
The idiomatic comparison is always against the member itself ā status == OrderStatus.PENDING ā never against the raw .value. Comparing against .value throws away exactly the type safety that motivated using an enum in the first place, since a raw string equality check has no way to catch a typo.
from enum import Enum
class OrderStatus(Enum):
PENDING = "pending"
SHIPPED = "shipped"
DELIVERED = "delivered"
status = OrderStatus.PENDING
print(status) # OrderStatus.PENDING
print(status.value) # 'pending'
print(status == OrderStatus.PENDING) # TrueDistinct identity, not just the string 'pending'
2StrEnum, IntEnum, and Interoperating With the Outside World
A plain Enum member is deliberately *not* equal to its underlying value by default (OrderStatus.PENDING != "pending"), which is exactly the safety property that makes typos loud instead of silent. But sometimes you genuinely need interoperability ā serializing directly to JSON, or comparing against a raw string that came from an external API or a database column.
StrEnum (3.11+) and IntEnum solve that by making the enum class itself a subclass of str or int respectively: Role.ADMIN == "admin" is True for a StrEnum, and json.dumps() serializes a StrEnum member directly as its string value with no custom encoder needed. Before 3.11, the equivalent pattern was class Role(str, Enum):, which is still valid and necessary if you need to support older interpreters.
The trade-off is real: mixing in str or int reintroduces some of the looseness a plain Enum avoids ā Role.ADMIN == "admin" now succeeds even from a typo-prone string literal that was never validated against the enum. Reach for StrEnum/IntEnum specifically at serialization or interop boundaries, and prefer a plain Enum internally wherever you control both sides of the comparison.
from enum import StrEnum
class Role(StrEnum):
ADMIN = "admin"
EDITOR = "editor"
print(Role.ADMIN == "admin") # True ā StrEnum IS a str subclass
import json
print(json.dumps({"role": Role.ADMIN})) # {"role": "admin"}'{"role": "admin"}' ā no custom encoder needed
3auto() and IntFlag for Combinable States
auto() removes the need to hand-assign a value when the specific underlying value is irrelevant ā only distinctness and, for IntEnum/IntFlag, ordering matter. By default it assigns increasing integers starting at 1, but you can override _generate_next_value_ on the enum class for custom auto-numbering schemes, such as string values derived from the member name.
IntFlag (and its string cousin, StrFlag conceptually, via Flag) is a distinct enum variant designed for *combinable* states ā permission sets, feature flags, style attributes ā where more than one member can be 'active' simultaneously. Members combine with the bitwise | operator, membership is tested with in, and auto() inside an IntFlag automatically assigns powers of two (1, 2, 4, 8...) so combinations don't collide.
The distinction to internalize: a regular Enum (or IntEnum) models 'exactly one of these states' ā an order is either PENDING or SHIPPED, never both ā while IntFlag models 'any subset of these states' ā a user can have READ and WRITE without EXECUTE. Reaching for the wrong one is a common design smell: representing a permission set as a plain Enum forces awkward workarounds like a separate member for every combination.
from enum import Enum, auto
class Direction(Enum):
NORTH = auto()
SOUTH = auto()
EAST = auto()
WEST = auto()
print(Direction.NORTH.value) # 1True
4Step-by-Step Breakdown
"pending", "PENDING", "Pending" ā three ways to introduce a bug with a single typo. Enums make that class of mistake a static-checker error instead of a runtime surprise.
A plain Enum gives each member a name and a value. Compare members by identity, never by their raw value string.
Checkpoint: What is the idiomatic way to compare a status value against a known state?
- āstatus == OrderStatus.PENDING (compare the enum member itself)
- āstatus.value == "pending" (compare the raw string)
StrEnum (3.11+) lets members behave as actual strings too ā useful when you need to serialize directly to JSON or compare with a raw string.
auto() assigns values automatically when you don't care about the underlying value, just distinctness.
Checkpoint: What does auto() do inside an Enum class body?
- āAutomatically assigns a distinct value, since the specific value does not matter
- āAssigns a random, non-deterministic value on every run
IntFlag lets you combine members with bitwise OR ā perfect for permission sets where multiple flags can be active at once.
With named constants and data shapes covered, __slots__ rounds out this section by addressing memory efficiency for classes with many instances.
Advance a Real Enum State. Finish next_status(): auto() assigns incrementing values so you never track them yourself.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Compare enum members by identity, never by .value
status == OrderStatus.PENDING preserves type safety; status.value == "pending" reintroduces the exact typo-prone string comparison enums exist to prevent.
Reach for StrEnum/IntEnum only at serialization or interop boundaries
Mixing in str or int makes an enum equal to raw values from outside your code, which is exactly what you want at an API/JSON boundary but reduces safety everywhere else ā keep plain Enum for purely internal logic.
Frequent Bugs
Modeling a set of independently-combinable flags (like permissions) as a plain Enum, then resorting to awkward extra members for every combination (READ_WRITE, READ_WRITE_EXECUTE, ...).
Use IntFlag with auto() for any state where more than one value can be active simultaneously; combine with | and test membership with in, instead of enumerating every combination by hand.
Real-World Examples
Validating a Status Transition State Machine
An order-processing system needs to enforce that orders can only move PENDING ā SHIPPED ā DELIVERED, never skip a step or move backward.
from enum import Enum
class OrderStatus(Enum):
PENDING = "pending"
SHIPPED = "shipped"
DELIVERED = "delivered"
VALID_TRANSITIONS = {
OrderStatus.PENDING: {OrderStatus.SHIPPED},
OrderStatus.SHIPPED: {OrderStatus.DELIVERED},
OrderStatus.DELIVERED: set(),
}
def can_transition(current: OrderStatus, target: OrderStatus) -> bool:
return target in VALID_TRANSITIONS[current]