Intermediate

If you have spent time debugging Python code, you have probably encountered the silent killers of software reliability: magic strings and magic numbers. A developer writes status = "active" in one function but checks if status == "Active" (capital A) in another. A bug is born. Days later, when the mismatch surfaces in production, your fingers itch to throttle the typo. Python enums exist precisely to prevent this nightmare. They transform those fragile string values into type-safe, self-documenting objects that your IDE can help you complete and your type checker can validate before a single line runs.

The good news: enums are built into Python’s standard library. No external packages needed, no complex installation steps. They integrate seamlessly with the language and work beautifully with type hints, making your code cleaner and more maintainable. Enums are also more than just a neat organizational trick — they are a best practice embraced by the Python community and used in production code across the industry, from web frameworks to data science pipelines.

In this tutorial, we will explore what enums are, why they matter, and how to use them effectively. We will start with the basics, move through automatic value generation, then advance to string enums, flags, and real-world patterns like state machines. By the end, you will understand when to reach for enums and how to wield them to write code that is both safer and more expressive.

Organized enum members versus scattered magic strings
Your code before and after enums. Spoiler: the enum side never has a typo.

Defining Your First Enum

Python’s enum module lives in the standard library — no install needed. The simplest enum is a subclass of Enum with class-level attributes for each member:

from enum import Enum

class Status(Enum):
    ACTIVE = "active"
    INACTIVE = "inactive"
    SUSPENDED = "suspended"

# Use members anywhere a status is expected
user_status = Status.ACTIVE
print(user_status)          # Status.ACTIVE
print(user_status.name)     # 'ACTIVE'
print(user_status.value)    # 'active'

# Iteration walks the members in definition order
for status in Status:
    print(status.name, status.value)

Three things to notice. First, members are accessed by attribute (Status.ACTIVE), not by string lookup. A typo like Status.ACTIV raises AttributeError at import time, not at runtime. Second, each member has both a name (the attribute identifier) and a value (whatever you assigned). Third, the enum class is iterable — you can loop over its members for dropdowns, validation, or any other “list all options” use case.

Auto-Generated Values with auto()

When you don’t care what the underlying value is — only that members are distinct — use auto() to let Python assign them:

from enum import Enum, auto

class HttpMethod(Enum):
    GET = auto()
    POST = auto()
    PUT = auto()
    DELETE = auto()

print(HttpMethod.GET.value)     # 1
print(HttpMethod.POST.value)    # 2

By default auto() generates integers starting at 1. To customize the sequence, override _generate_next_value_:

class CssClass(Enum):
    def _generate_next_value_(name, start, count, last_values):
        return name.lower().replace('_', '-')
    PRIMARY = auto()        # 'primary'
    PRIMARY_LIGHT = auto()  # 'primary-light'

String Enums and IntEnum

Sometimes you want the enum to also behave like its underlying type — so Status.ACTIVE == "active" returns True and the value serializes cleanly to JSON. Use StrEnum (Python 3.11+) for strings or IntEnum for integers:

from enum import StrEnum, IntEnum

class Priority(IntEnum):
    LOW = 1
    MEDIUM = 5
    HIGH = 10

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

# IntEnum behaves like int
print(Priority.HIGH > Priority.LOW)    # True
print(Priority.HIGH + 5)               # 15

# StrEnum behaves like str
print(Color.RED == "red")              # True
print(Color.GREEN.upper())             # 'GREEN'
import json
print(json.dumps({"color": Color.RED}))  # '{"color": "red"}' — clean serialization

This is the practical choice for API payloads, database column values, and anything that crosses a serialization boundary. The enum gives you type-safety inside your code; the underlying type gives you painless JSON / SQL / config-file output.

Bit Flags with Flag and IntFlag

For “any combination of these options” — file permissions, feature toggles, network capabilities — use Flag (or IntFlag for compatibility with bitwise int operations). Members can be ORed together to form combinations:

from enum import Flag, auto

class Permission(Flag):
    READ = auto()
    WRITE = auto()
    EXECUTE = auto()
    ADMIN = READ | WRITE | EXECUTE   # combination shortcut

# Combine at use site
user_perms = Permission.READ | Permission.WRITE
print(Permission.READ in user_perms)        # True
print(Permission.EXECUTE in user_perms)     # False
print(bool(user_perms & Permission.READ))   # True

Bit flags are 100x more readable than raw integers (perms = 0b110) and 10x more readable than dictionaries of bools ({"read": True, "write": True}). Anywhere you’d reach for a set of strings as a “kind of bitfield”, reach for Flag instead.

A Real Example: State Machine

Enums shine when modeling state transitions. Here’s an order-status state machine that rejects illegal transitions at the type level:

from enum import Enum

class OrderStatus(Enum):
    DRAFT = "draft"
    PENDING = "pending"
    PAID = "paid"
    SHIPPED = "shipped"
    DELIVERED = "delivered"
    CANCELLED = "cancelled"

ALLOWED_TRANSITIONS = {
    OrderStatus.DRAFT: {OrderStatus.PENDING, OrderStatus.CANCELLED},
    OrderStatus.PENDING: {OrderStatus.PAID, OrderStatus.CANCELLED},
    OrderStatus.PAID: {OrderStatus.SHIPPED, OrderStatus.CANCELLED},
    OrderStatus.SHIPPED: {OrderStatus.DELIVERED},
    OrderStatus.DELIVERED: set(),
    OrderStatus.CANCELLED: set(),
}

def transition(current: OrderStatus, target: OrderStatus) -> OrderStatus:
    if target not in ALLOWED_TRANSITIONS[current]:
        raise ValueError(f"Illegal transition: {current.value} -> {target.value}")
    return target

# Usage
state = OrderStatus.DRAFT
state = transition(state, OrderStatus.PENDING)  # OK
state = transition(state, OrderStatus.PAID)     # OK
# state = transition(state, OrderStatus.DRAFT)  # raises ValueError

The enum + transition map combo gives you a self-documenting workflow that’s impossible to misuse. Add type hints (state: OrderStatus) and mypy will catch any caller that tries to pass a raw string.

Common Pitfalls

  • Enums aren’t tuples. Status.ACTIVE is not a string, even though it might display like one. Use .value when you need the underlying primitive, or pick StrEnum / IntEnum to get implicit type compatibility.
  • Singleton equality. Two members with the same value collapse into a single member by default. class X(Enum): A = 1; B = 1 makes X.B an alias for X.A. If you want distinct members with the same value, you need a workaround (custom __init__ or a unique discriminator).
  • JSON serialization. json.dumps(Status.ACTIVE) raises TypeError for plain Enum but works for StrEnum / IntEnum. Either subclass StrEnum or write a custom default= handler.
  • Comparison with strings. Status.ACTIVE == "active" is False for a plain Enum (different types) but True for StrEnum. Pick the right base class up front to avoid surprises.
  • Don’t subclass an enum that already has members. Python forbids it: once you define members, the class is closed for extension. Refactor to a common base or use composition.

FAQ

Q: Should I use a plain Enum, IntEnum, or StrEnum?
A: StrEnum for API/JSON values where the string matters externally. IntEnum for bitwise math or legacy integer-coded statuses. Plain Enum for internal-only values where the underlying type shouldn’t leak into comparisons.

Q: How do I get an enum member from its value?
A: Call the enum like a function: Status("active") returns Status.ACTIVE. Wrap in try/except ValueError for unknown values, or use Status._value2member_map_.get(...) for a no-exception lookup.

Q: Can enum members have extra attributes or methods?
A: Yes — enums are real classes. Override __init__ to attach data, and add regular methods on the class body. A common pattern is class Country(Enum): US = ("United States", "USD"); GB = ("United Kingdom", "GBP") with each member carrying a tuple of attributes.

Q: Are enums faster than string constants?
A: Marginally. The real win is correctness, not speed. Avoid bench-marking enums against strings — you’ll measure noise.

Q: How do mypy / Pyright / Pylance handle enums?
A: They love them. Type-narrowing with if status is Status.ACTIVE: works correctly, and unknown member access is caught at lint time. Enums are one of Python’s most type-checker-friendly features.

Wrapping Up

Reach for an enum any time you have a fixed, named set of values — order statuses, user roles, HTTP methods, log levels, permissions. Plain Enum for internal identifiers, StrEnum for JSON-friendly values, IntEnum for bit math, Flag for combinations. The cost is one extra class definition; the payoff is a typo never silently breaks production.

For the complete reference, see the official Python enum documentation.

Related Python Tutorials

Continue learning with these related guides: