Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 073 / 100

Python enums and Literal types: best practices explained

Model fixed sets of values safely.

Expona Admin8 min read

Introduction

When building production systems, we often work with fixed sets of values—order statuses, payment methods, user roles, API response codes. Managing these safely is where Python enums and Literal types: best practices explained becomes essential. In this article, we'll explore how to model fixed sets of values safely, avoiding string magic and runtime errors that slip through without proper typing.

The choice between Enum, StrEnum, and Literal types isn't just about aesthetics; it affects how your team catches bugs, how well your IDE supports you, and how maintainable your codebase becomes over time. We'll walk through each approach with real-world scenarios you'll encounter in production backends.

Enum and StrEnum for named constants

The traditional Enum class is the foundation for representing a fixed set of related values. Each member gets a name and a value, and you access it as MyEnum.MEMBER_NAME. This gives you type safety, IDE autocomplete, and prevents typos that would otherwise result in AttributeError at runtime.

python
from enum import Enum

class OrderStatus(Enum):
    PENDING = "pending"
    PROCESSING = "processing"
    SHIPPED = "shipped"
    DELIVERED = "delivered"
    CANCELLED = "cancelled"

# Usage
status = OrderStatus.PENDING
print(status.value)  # "pending"
print(status.name)   # "PENDING"

In practice, we frequently serialize enums to JSON or store them in databases. The traditional Enum requires manual conversion, which adds boilerplate:

python
import json

# Awkward serialization
order_status = OrderStatus.SHIPPED
json_str = json.dumps({"status": order_status.value})  # Manual .value

This is where StrEnum (introduced in Python 3.11) shines. A StrEnum member is both an Enum and a string simultaneously, eliminating the need for explicit .value calls:

python
from enum import StrEnum
import json

class OrderStatus(StrEnum):
    PENDING = "pending"
    PROCESSING = "processing"
    SHIPPED = "shipped"
    DELIVERED = "delivered"
    CANCELLED = "cancelled"

# Seamless serialization
order_status = OrderStatus.SHIPPED
json_str = json.dumps({"status": order_status})  # Works directly
print(json_str)  # {"status": "shipped"}

# Comparison works intuitively
if order_status == "shipped":
    print("Order shipped!")

Use StrEnum when your enum values are strings that get serialized frequently—API responses, database records, configuration files. The ergonomics alone justify the upgrade if you're on Python 3.11+.

Literal for simple string unions

Literal types (from typing) offer a lightweight alternative when you don't need the full machinery of Enum. If you have a small set of string values that don't require methods, iteration, or serialization logic, Literal is faster to write and equally type-safe:

python
from typing import Literal

OrderStatusLiteral = Literal["pending", "processing", "shipped", "delivered", "cancelled"]

def update_order(order_id: int, status: OrderStatusLiteral) -> None:
    print(f"Updating order {order_id} to {status}")

# Type checker catches this
update_order(123, "invalid_status")  # Type error in mypy/pyright

Literal is ideal for function signatures where you want to restrict input without the overhead of defining an Enum class. It's particularly common in API handler decorators or validation functions:

python
from typing import Literal

def handle_webhook(event_type: Literal["order.created", "order.updated", "order.cancelled"]) -> dict:
    match event_type:
        case "order.created":
            return {"action": "notify_warehouse"}
        case "order.updated":
            return {"action": "notify_customer"}
        case "order.cancelled":
            return {"action": "process_refund"}

# At the call site, type checker ensures valid event types
result = handle_webhook("order.created")

However, Literal doesn't support iteration or value lookup without the original type definition. If you need to loop through valid values or build a dropdown menu, Enum is the better choice.

Pydantic validates both

When parsing untrusted input—from API requests, message queues, or form submissions—you need runtime validation. Pydantic handles both StrEnum and Literal types elegantly, coercing strings to enum members and validating Literal constraints:

python
from pydantic import BaseModel, ValidationError
from enum import StrEnum
from typing import Literal

class PaymentMethod(StrEnum):
    CREDIT_CARD = "credit_card"
    BANK_TRANSFER = "bank_transfer"
    PAYPAL = "paypal"

class CheckoutRequest(BaseModel):
    payment_method: PaymentMethod
    currency: Literal["USD", "EUR", "GBP"]

# Valid input
payload = {"payment_method": "credit_card", "currency": "USD"}
request = CheckoutRequest(**payload)
print(request.payment_method)  # PaymentMethod.CREDIT_CARD

# Invalid input
try:
    bad_payload = {"payment_method": "crypto", "currency": "DOGE"}
    CheckoutRequest(**bad_payload)
except ValidationError as e:
    print(e)  # Pydantic reports both field violations

Pydantic's serialization_alias and validation_alias features also let you map between different representations—useful when your API uses snake_case but your internal code uses PascalCase:

python
from pydantic import BaseModel, Field
from enum import StrEnum

class UserRole(StrEnum):
    ADMIN = "admin"
    MODERATOR = "moderator"
    USER = "user"

class UserProfile(BaseModel):
    name: str
    role: UserRole = Field(validation_alias="user_role")

# Incoming JSON uses "user_role"
data = {"name": "Alice", "user_role": "admin"}
profile = UserProfile(**data)
print(profile.role)  # UserRole.ADMIN

This validation layer catches malformed input before it reaches your business logic, reducing defensive coding and making error handling centralized and predictable.

Match statements work well with enums

Python 3.10 introduced match/case statements, which pair beautifully with enums. Pattern matching makes branching on enum values explicit and exhaustive:

python
from enum import StrEnum

class PaymentStatus(StrEnum):
    PENDING = "pending"
    APPROVED = "approved"
    DECLINED = "declined"
    REFUNDED = "refunded"

def handle_payment_status(status: PaymentStatus) -> str:
    match status:
        case PaymentStatus.PENDING:
            return "Awaiting payment processor response..."
        case PaymentStatus.APPROVED:
            return "Payment received. Preparing shipment."
        case PaymentStatus.DECLINED:
            return "Payment declined. Please try another method."
        case PaymentStatus.REFUNDED:
            return "Refund processed."
        case _:
            # This should never happen if using StrEnum
            return "Unknown status"

# Type checkers can warn about missing cases
result = handle_payment_status(PaymentStatus.APPROVED)

The advantage over if/elif chains is readability and the type checker's ability to warn about unhandled cases. Modern linters (like Pylint and Pyright) can flag missing patterns, helping catch logic bugs during development rather than in production.

For more complex workflows, you might pattern-match on both the enum and additional context:

python
from dataclasses import dataclass
from enum import StrEnum

class OrderEvent(StrEnum):
    CREATED = "created"
    PAID = "paid"
    SHIPPED = "shipped"

@dataclass
class OrderTransition:
    event: OrderEvent
    retries: int

def process_order_event(transition: OrderTransition) -> str:
    match transition:
        case OrderTransition(event=OrderEvent.CREATED, retries=0):
            return "New order, first attempt"
        case OrderTransition(event=OrderEvent.CREATED, retries=n) if n > 0:
            return f"Retry attempt {n} for order creation"
        case OrderTransition(event=OrderEvent.PAID):
            return "Payment confirmed"
        case OrderTransition(event=OrderEvent.SHIPPED):
            return "Order dispatched"

# Combining structural matching with enum safety
event = OrderTransition(event=OrderEvent.CREATED, retries=2)
print(process_order_event(event))

Common mistakes

Mixing strings and enums without validation: A common pitfall is accepting both enum members and strings in the same function, leading to inconsistent behavior. Always enforce one representation at the boundary (use Pydantic validators or type annotations).

python

# Anti-pattern: accepts anything
def process_status(status):  # No type hint
    if status == "pending" or status == OrderStatus.PENDING:
        print("Processing...")
    # Fragile and unclear

# Better: enforce enum membership
from typing import Union

def process_status(status: OrderStatus) -> None:
    if status == OrderStatus.PENDING:
        print("Processing...")

Overusing Literal for complex logic: If your code requires methods on the enum or iteration, Literal forces you to duplicate the values elsewhere. Use Enum from the start.

Forgetting to update match cases: When adding new enum members, old match statements won't warn you about missing cases unless you remove the fallback case. Use type checkers strictly during development.

Comparing Literal values to strings at runtime: Literal is a type hint only—it has no runtime representation. Never assume a function argument is a Literal at runtime. Use Enum if you need runtime validation without Pydantic.

python

# This works (Pydantic handles it)
class Request(BaseModel):
    method: Literal["GET", "POST"]

# This does NOT work (no runtime type at the function level)
def my_func(method: Literal["GET", "POST"]):
    # method is just a string here at runtime
    # type checker only catches misuse at call sites
    pass

Key takeaways

  • Use StrEnum for fixed sets of string values in Python 3.11+. It combines Enum's safety with string's ergonomics, especially for serialization and comparison.
  • Use Literal for lightweight type hints in function signatures when you don't need iteration or methods on the values.
  • Validate with Pydantic at input boundaries (API handlers, message consumers). Pydantic coerces strings to enum members and validates Literal constraints, catching bad data early.
  • Leverage match statements with enums for exhaustive, type-safe branching logic. Modern linters will warn about unhandled cases.
  • Choose the right tool: Enum for state machines and complex logic, Literal for simple type annotations, StrEnum for production APIs, Pydantic for validation.
  • Keep enum definitions centralized. Import from a single module to avoid duplication and ensure consistency across your codebase.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles