Intermediate

You’ve seen it in every codebase: a string constant like "pending" scattered across thirty files, a magic integer like 2 that means “admin role” but is never defined anywhere, a status field that accepts anything and silently breaks when someone types "Pending" with a capital P. These are the symptoms of code that uses raw literals instead of named constants — and Python’s enum module is the standard fix. Enums are not just constants with names. They are types with identity, comparison semantics, iteration support, and first-class integration with type checkers and Python’s match statement.

The enum module ships in the Python standard library — no installation required. It has been available since Python 3.4, and the variants you need most (Enum, IntEnum, StrEnum, Flag) are all in one import. Python 3.11 added StrEnum and improved IntEnum behavior, so the examples in this article require Python 3.11 or later for the StrEnum sections. Everything else works from Python 3.4 onward.

This article covers everything you need to use enums confidently. We’ll start with a quick working example, then walk through the core enum types (Enum, IntEnum, StrEnum, Flag), iteration and comparison, using enums in match statements, and when NOT to use them. There is a real-life project at the end — a task management system — that ties all of it together.

Python Enums: Quick Example

This minimal example shows the core pattern. Define constants once, reference them by name everywhere, and let Python handle comparison and validation.

# enums_quick.py
from enum import Enum, auto

class OrderStatus(Enum):
    PENDING = auto()
    PROCESSING = auto()
    SHIPPED = auto()
    DELIVERED = auto()
    CANCELLED = auto()

def describe_order(status: OrderStatus) -> str:
    if status == OrderStatus.PENDING:
        return "Your order is waiting to be processed."
    elif status == OrderStatus.SHIPPED:
        return "Your order is on the way!"
    elif status == OrderStatus.DELIVERED:
        return "Your order has arrived."
    elif status == OrderStatus.CANCELLED:
        return "Your order was cancelled."
    else:
        return f"Order is {status.name.lower()}."

current = OrderStatus.SHIPPED
print(describe_order(current))
print(f"Status name: {current.name}")
print(f"Status value: {current.value}")
print(f"Is shipped: {current == OrderStatus.SHIPPED}")

Output:

Your order is on the way!
Status name: SHIPPED
Status value: 3
Is shipped: True

auto() assigns integer values automatically — you never have to pick or remember them. Each enum member has a .name (the Python attribute name) and a .value (the assigned value). Comparison with == checks identity, not just value — OrderStatus.SHIPPED == 3 is False unless you use IntEnum. That strictness is usually what you want, because it prevents code that accidentally mixes raw integers with enum values from silently doing the wrong thing.

What Are Enums and When Should You Use Them?

An enum (short for enumeration) is a set of named constants that form a logical group. Python’s Enum class turns these constants into a proper type: members are unique, iterable, comparable, and serializable. You can think of an enum as a named lookup table where each entry is an immutable, singleton object.

The right time to reach for an enum is whenever you have a fixed set of related values that represent distinct states, categories, or options. The wrong time is when the set of values is dynamic (loaded from a database at runtime), unbounded (any string is valid), or purely numeric with no semantic meaning (a count, an ID, a score).

SituationUse enum?Why
Order status: pending, shipped, deliveredYesFixed set, semantic meaning, type-checked
HTTP methods: GET, POST, PUT, DELETEYesFixed set, comparison matters, readable in logs
User role loaded from DB at runtimeNoDynamic set — use a string or int validated at boundaries
Permission flags that combine (read | write)Yes (Flag)Bitwise combination is exactly what Flag is for
Constant pi or gravitational constantNoSingle value — use a module-level constant
Days of the weekYesFixed, ordered, iterable — classic enum use case

The practical test: if you find yourself writing if status == "pending" in more than one place, you should probably be writing if status == OrderStatus.PENDING instead. Enums make typos into AttributeErrors caught at load time rather than silent bugs caught at 3am.

Basic Enum: Named Constants With Identity

The base Enum class is the most common starting point. Values can be anything — integers, strings, tuples — but the members are compared by identity, not by value. This means two enums with the same value but different names are different members (unless they are aliases, which we cover below).

# basic_enum.py
from enum import Enum, auto

class Direction(Enum):
    NORTH = auto()
    SOUTH = auto()
    EAST = auto()
    WEST = auto()

    def opposite(self) -> "Direction":
        opposites = {
            Direction.NORTH: Direction.SOUTH,
            Direction.SOUTH: Direction.NORTH,
            Direction.EAST: Direction.WEST,
            Direction.WEST: Direction.EAST,
        }
        return opposites[self]

# Access members by name or value
print(Direction.NORTH)          # Direction.NORTH
print(Direction.NORTH.name)     # NORTH
print(Direction.NORTH.value)    # 1

# Look up a member by value
d = Direction(2)
print(d)                        # Direction.SOUTH

# Look up by name
d2 = Direction["EAST"]
print(d2)                       # Direction.EAST

# Enum methods work naturally
current = Direction.NORTH
print(f"Heading {current.name}, opposite is {current.opposite().name}")

# Iteration
print("All directions:", [d.name for d in Direction])

# Membership test
print(Direction.WEST in Direction)   # True

Output:

Direction.NORTH
NORTH
1
Direction.SOUTH
Direction.EAST
Heading NORTH, opposite is SOUTH
All directions: ['NORTH', 'SOUTH', 'EAST', 'WEST']
True

A few things to notice. You can add methods to an enum class — opposite() is a real method that returns another enum member. The Direction(2) lookup-by-value is useful when deserializing data from an API or database. The Direction["EAST"] lookup-by-name is useful when parsing config strings. Both raise ValueError or KeyError on invalid input, giving you early validation rather than silent failures downstream.

IntEnum and StrEnum: When Values Matter

The base Enum does not compare equal to its underlying value. This is deliberate — it prevents accidentally passing an integer where an enum is expected. But sometimes you genuinely need the enum value to behave like its underlying type: when writing to a database that stores integers, or when generating JSON that needs plain strings. That is where IntEnum and StrEnum come in.

# intenum_strenum.py
from enum import IntEnum, StrEnum  # StrEnum requires Python 3.11+

class Priority(IntEnum):
    LOW = 1
    MEDIUM = 2
    HIGH = 3
    CRITICAL = 4

class Color(StrEnum):
    RED = "red"
    GREEN = "green"
    BLUE = "blue"

# IntEnum compares with integers directly
task_priority = Priority.HIGH
print(task_priority > 2)              # True  (behaves like int 3)
print(task_priority == 3)             # True
print(sorted([Priority.CRITICAL, Priority.LOW, Priority.MEDIUM]))  # sorted!

# IntEnum values work in numeric contexts
tasks_today = {Priority.LOW: 5, Priority.HIGH: 2}
total_weighted = sum(p * count for p, count in tasks_today.items())
print(f"Weighted load: {total_weighted}")   # 1*5 + 3*2 = 11

# StrEnum compares with strings directly
html_color = Color.RED
print(html_color == "red")            # True
print(f"CSS: color: {html_color};")   # Works directly in f-strings
print(isinstance(html_color, str))    # True

# Use StrEnum in dict lookups without converting
config = {"red": "#FF0000", "green": "#00FF00", "blue": "#0000FF"}
print(config[Color.BLUE])             # #0000FF -- lookup works directly

Output:

True
True
[<Priority.LOW: 1>, <Priority.MEDIUM: 2>, <Priority.HIGH: 3>]
Weighted load: 11
True
CSS: color: red;
True
#0000FF

The tradeoff with IntEnum and StrEnum is that you give up some type safety — code that passes raw integers or strings will no longer be caught by a type checker. Use them when interoperability with external systems (databases, APIs, config files) is more important than strict type boundaries. For internal domain logic, prefer the base Enum.

Flag Enums: Combining Options

The Flag class is for situations where values can be combined — file permissions, feature flags, user capabilities. Each member is a power of two, and you combine them with the | operator. Checking membership uses the in operator or &.

# flag_enum.py
from enum import Flag, auto

class Permission(Flag):
    READ = auto()       # 1
    WRITE = auto()      # 2
    EXECUTE = auto()    # 4
    DELETE = auto()     # 8

    # Convenience aliases
    READ_WRITE = READ | WRITE
    ADMIN = READ | WRITE | EXECUTE | DELETE

def check_access(user_perms: Permission, required: Permission) -> bool:
    return required in user_perms

# Combine permissions with |
guest_perms = Permission.READ
editor_perms = Permission.READ | Permission.WRITE
admin_perms = Permission.ADMIN

print(f"Guest:  {guest_perms}")
print(f"Editor: {editor_perms}")
print(f"Admin:  {admin_perms}")

# Check individual permissions
print(check_access(editor_perms, Permission.WRITE))    # True
print(check_access(guest_perms, Permission.WRITE))     # False
print(check_access(admin_perms, Permission.DELETE))    # True

# Iterate over combined flags to see components
print("Admin permissions:")
for perm in admin_perms:
    print(f"  - {perm.name}")

Output:

Guest:  Permission.READ
Editor: Permission.READ|WRITE
Admin:  Permission.READ|WRITE|EXECUTE|DELETE
True
False
True
Admin permissions:
  - READ
  - WRITE
  - EXECUTE
  - DELETE

Flag is the cleanest way to represent additive permission sets in Python. The in operator checks whether a flag is set in a combination, and iterating over a combined value yields each individual flag that is included. This replaces the old pattern of bitmasking integers manually, and makes the code both readable and type-safe.

Enums in match Statements

Python 3.10’s match statement works naturally with enums and is a cleaner alternative to chains of if/elif when you need to handle multiple enum cases. The exhaustive pattern matching also helps type checkers warn you when you forget a case.

# enums_match.py
from enum import Enum, auto

class TrafficLight(Enum):
    RED = auto()
    YELLOW = auto()
    GREEN = auto()

def get_action(light: TrafficLight) -> str:
    match light:
        case TrafficLight.RED:
            return "Stop completely."
        case TrafficLight.YELLOW:
            return "Prepare to stop."
        case TrafficLight.GREEN:
            return "Proceed when safe."
        case _:
            return "Unknown signal -- stop and assess."

# Cycle through all states
for state in TrafficLight:
    print(f"{state.name}: {get_action(state)}")

Output:

RED: Stop completely.
YELLOW: Prepare to stop.
GREEN: Proceed when safe.

The match statement with enums is especially readable because each case is self-documenting — case TrafficLight.RED is unambiguous in a way that case 1 never is. Type checkers like mypy can detect missing cases when combined with _ fallthrough, giving you a compile-time reminder to handle new enum members you add later.

Real-Life Example: Task Management System

This project uses enums throughout a task management system: task status, priority, and user permissions all use the appropriate enum type. The system routes tasks, checks permissions, and generates a report.

# task_manager.py
from enum import Enum, Flag, StrEnum, auto
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional

class TaskStatus(Enum):
    TODO = auto()
    IN_PROGRESS = auto()
    BLOCKED = auto()
    REVIEW = auto()
    DONE = auto()

class Priority(StrEnum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"
    CRITICAL = "critical"

class TeamPermission(Flag):
    VIEW = auto()
    COMMENT = auto()
    EDIT = auto()
    CLOSE = auto()
    MANAGE = VIEW | COMMENT | EDIT | CLOSE

@dataclass
class Task:
    title: str
    status: TaskStatus = TaskStatus.TODO
    priority: Priority = Priority.MEDIUM
    assignee: Optional[str] = None
    created_at: datetime = field(default_factory=datetime.now)

    def transition(self, new_status: TaskStatus) -> None:
        valid_transitions = {
            TaskStatus.TODO: {TaskStatus.IN_PROGRESS},
            TaskStatus.IN_PROGRESS: {TaskStatus.BLOCKED, TaskStatus.REVIEW, TaskStatus.DONE},
            TaskStatus.BLOCKED: {TaskStatus.IN_PROGRESS},
            TaskStatus.REVIEW: {TaskStatus.IN_PROGRESS, TaskStatus.DONE},
            TaskStatus.DONE: set(),
        }
        if new_status not in valid_transitions[self.status]:
            raise ValueError(
                f"Cannot transition {self.status.name} -> {new_status.name}. "
                f"Valid: {[s.name for s in valid_transitions[self.status]]}"
            )
        self.status = new_status

def can_close_task(perms: TeamPermission) -> bool:
    return TeamPermission.CLOSE in perms

def print_report(tasks: list[Task]) -> None:
    by_status: dict[TaskStatus, list[Task]] = {s: [] for s in TaskStatus}
    for task in tasks:
        by_status[task.status].append(task)

    print("\n=== Task Board Report ===")
    for status, group in by_status.items():
        if group:
            print(f"\n[{status.name}]")
            for task in group:
                assignee = task.assignee or "unassigned"
                print(f"  [{task.priority.upper()}] {task.title} ({assignee})")

# --- Demo ---
tasks = [
    Task("Write API docs", priority=Priority.HIGH, assignee="alice"),
    Task("Fix login bug", priority=Priority.CRITICAL, assignee="bob"),
    Task("Update dependencies", priority=Priority.LOW),
    Task("Code review PR #42", priority=Priority.MEDIUM, assignee="carol"),
]

# Transition tasks through statuses
tasks[0].transition(TaskStatus.IN_PROGRESS)
tasks[1].transition(TaskStatus.IN_PROGRESS)
tasks[1].transition(TaskStatus.REVIEW)
tasks[2].transition(TaskStatus.IN_PROGRESS)
tasks[2].transition(TaskStatus.DONE)

# Permission check
dev_perms = TeamPermission.VIEW | TeamPermission.COMMENT | TeamPermission.EDIT
lead_perms = TeamPermission.MANAGE
print(f"Dev can close tasks: {can_close_task(dev_perms)}")
print(f"Lead can close tasks: {can_close_task(lead_perms)}")

# Report
print_report(tasks)

Output:

Dev can close tasks: False
Lead can close tasks: True

=== Task Board Report ===

[IN_PROGRESS]
  [HIGH] Write API docs (alice)
  [LOW] Update dependencies (unassigned)

[REVIEW]
  [CRITICAL] Fix login bug (bob)

[DONE]
  [LOW] Update dependencies (unassigned)

[TODO]
  [MEDIUM] Code review PR #42 (carol)

This system uses three different enum types for three different purposes: Enum for status (identity matters, no comparison to raw values needed), StrEnum for priority (values are passed directly to display strings and API responses), and Flag for permissions (combinations are the whole point). The transition() method enforces valid state changes using the enum itself as the key in a transition table — a pattern that makes invalid state transitions into immediate ValueErrors rather than silent data corruption. You can extend this by adding a @classmethod from_string to each enum for loading from database rows or API payloads.

Frequently Asked Questions

How do I serialize enums to JSON?

The standard json module does not know how to serialize enums by default — you will get a TypeError. The cleanest fix is a custom encoder: class EnumEncoder(json.JSONEncoder): def default(self, obj): return obj.value if isinstance(obj, Enum) else super().default(obj). Then pass cls=EnumEncoder to json.dumps(). Alternatively, if you are using StrEnum or IntEnum, the values serialize directly since they are plain strings or ints. For loading from JSON, use MyEnum(raw_value) to reconstruct the enum member at the deserialization boundary.

What values does auto() assign?

auto() assigns integers starting from 1 by default, incrementing by 1 for each member. You can override this behavior by defining _generate_next_value_(name, start, count, last_values) in your enum class. For example, returning name.lower() from that method makes auto() produce lowercase string values matching the member names — a shortcut that StrEnum formalized. The value of auto() is that you never have to manually number your enum members or worry about gaps when you reorder or add members.

What are enum aliases and how do I prevent them?

If two enum members have the same value, the second is an alias for the first — accessing it returns the first member. This is intentional for cases like TUESDAY = TUES = 2. To prevent accidental aliases (and get an error if you accidentally duplicate values), use the @unique decorator from the enum module: @enum.unique above the class definition raises a ValueError at class creation time if any duplicate values exist. This is a good default for most enums where each value should be distinct.

When should I use Enum vs Literal?

typing.Literal["pending", "shipped", "done"] is a type annotation for functions that accept a fixed set of string values. It is pure typing with no runtime behavior — you can pass any string and Python will not complain at runtime, only the type checker will. Enum is a runtime construct: invalid values are caught when you try to construct a member (OrderStatus("invalid") raises ValueError). Use Literal when you are annotating function signatures that accept existing string constants you do not control. Use Enum when you own the constants and want runtime validation, iteration, and type-checker integration.

What is the functional Enum API?

Python’s enum module supports a functional syntax for creating enums dynamically: Status = Enum("Status", ["PENDING", "SHIPPED", "DONE"]). This creates the same Status enum class as the class syntax, but is useful when the members are not known until runtime — for example, loading status names from a database or config file. Values are assigned starting from 1. You can also pass a dict to control values: Enum("Color", {"RED": "#FF0000", "GREEN": "#00FF00"}). The functional API produces real enum classes, not just dicts, so all the normal enum features (iteration, comparison, methods) work as expected.

Conclusion

Python enums replace the most common source of silent bugs — magic strings and integers — with named, type-checked constants that are safe to compare, iterate, and serialize. We covered the four most important types: base Enum for named constants with strict comparison, IntEnum and StrEnum for interoperability with external systems, and Flag for combinable permission and option sets. We saw how auto() removes the need to manually assign values, how enums integrate with match statements for exhaustive pattern matching, and how to enforce valid state transitions using enum-keyed transition tables.

The next step is to replace the oldest magic constants in your codebase. Start with any field that stores a status, state, type, or category as a raw string or integer — those are the high-value targets. Add @enum.unique to catch accidental value duplication, and add a classmethod for loading from external sources. For the full reference, the Python enum documentation covers every detail including functional API, mixin classes, and data enums added in Python 3.12.