Skip to content

Replace Boolean Soup with Python Enum and Match/Case

The Problem

I had a function with way too many boolean flags. It looked like this:

boolean_soup.py
def process_order(order):
if order.is_pending and not order.is_paid and not order.is_shipped:
# Handle pending order
pass
elif order.is_paid and not order.is_shipped and not order.is_cancelled:
# Handle paid order waiting for shipment
pass
elif order.is_shipped and not order.is_delivered:
# Handle shipped order
pass
elif order.is_cancelled:
# Handle cancelled order
pass
elif order.is_delivered:
# Handle delivered order
pass
else:
# What state is this???
pass

I kept asking myself: What happens when both is_pending and is_paid are true? Is that even valid? What about is_cancelled and is_shipped together?

This is “boolean soup” - a mess of overlapping boolean flags that should be mutually exclusive states. The code is hard to read, hard to debug, and very easy to break.

The Solution: Enum with Match/Case

I refactored this to use Python’s enum.Enum combined with match/case (available in Python 3.10+). Here’s what I ended up with:

order_states.py
from enum import Enum, auto
class OrderStatus(Enum):
PENDING = auto()
PAID = auto()
SHIPPED = auto()
DELIVERED = auto()
CANCELLED = auto()

The auto() function automatically assigns integer values (1, 2, 3, …), so I don’t have to manually set them. The state names are self-documenting.

Now my processing function looks like this:

clean_process.py
def process_order(order):
match order.status:
case OrderStatus.PENDING:
handle_pending(order)
case OrderStatus.PAID:
handle_paid(order)
case OrderStatus.SHIPPED:
handle_shipped(order)
case OrderStatus.DELIVERED:
handle_delivered(order)
case OrderStatus.CANCELLED:
handle_cancelled(order)

This is much clearer. Each state has exactly one handler, and there’s no ambiguity about overlapping conditions.

Before and After Comparison

Let me show you the full refactoring. Here’s the “before” version with boolean soup:

order_before.py
class Order:
def __init__(self):
self.is_pending = True
self.is_paid = False
self.is_shipped = False
self.is_delivered = False
self.is_cancelled = False
def can_transition_to_shipped(self):
# Complex logic to check if we can ship
return (self.is_paid and
not self.is_shipped and
not self.is_delivered and
not self.is_cancelled)
def can_transition_to_cancelled(self):
# Another complex check
return (self.is_pending or self.is_paid) and not self.is_shipped
def transition_to_shipped(self):
if self.can_transition_to_shipped():
self.is_shipped = True
# What about is_pending? is_paid? Keep them all?
# This is confusing!
return True
return False

I had to track multiple boolean flags, and I was never sure which ones should be set or cleared during transitions.

Here’s the “after” version with Enum:

order_after.py
from enum import Enum, auto
class OrderStatus(Enum):
PENDING = auto()
PAID = auto()
SHIPPED = auto()
DELIVERED = auto()
CANCELLED = auto()
class Order:
# Define valid transitions
VALID_TRANSITIONS = {
OrderStatus.PENDING: {OrderStatus.PAID, OrderStatus.CANCELLED},
OrderStatus.PAID: {OrderStatus.SHIPPED, OrderStatus.CANCELLED},
OrderStatus.SHIPPED: {OrderStatus.DELIVERED},
OrderStatus.DELIVERED: set(), # Terminal state
OrderStatus.CANCELLED: set(), # Terminal state
}
def __init__(self):
self.status = OrderStatus.PENDING
def can_transition_to(self, new_status):
return new_status in self.VALID_TRANSITIONS[self.status]
def transition_to(self, new_status):
if self.can_transition_to(new_status):
self.status = new_status
return True
raise ValueError(
f"Cannot transition from {self.status} to {new_status}"
)

The state transitions are now explicit and self-documenting. I can see at a glance which states can transition to which other states.

State Transition Diagram

With Enum, I can visualize the valid state flow:

PENDING ──┬──> PAID ──┬──> SHIPPED ──> DELIVERED
│ │
│ └──> CANCELLED
└──> CANCELLED

This diagram is derived directly from the VALID_TRANSITIONS dictionary. It’s impossible to transition from DELIVERED to anything else - that’s a terminal state.

Type Safety with mypy and pyright

One big advantage of using Enum is type checking. With boolean flags, mypy can’t help me:

boolean_notypecheck.py
# Boolean soup - no type checking possible
order.is_pending = True
order.is_paid = True # Is this valid? mypy doesn't know
order.is_shipped = True # Can both be true? No warning

With Enum, I get type safety:

enum_typecheck.py
# Type-safe - mypy/pyright will catch errors
order.status = OrderStatus.PENDING # OK
order.status = OrderStatus.PAID # OK
order.status = "paid" # Type error! mypy/pyright will complain
order.status = 1 # Type error!

When I run mypy:

terminal
$ mypy order_after.py
order_after.py:25: error: Incompatible types in assignment
(expression has type "str", variable has type "OrderStatus")

This catches bugs at development time instead of runtime.

Match/Case Pattern Matching

Python 3.10 introduced structural pattern matching with match/case. Combined with Enum, it makes state handling clean:

match_case.py
def get_order_message(order):
match order.status:
case OrderStatus.PENDING:
return "Your order is being processed"
case OrderStatus.PAID:
return "Payment received, preparing for shipment"
case OrderStatus.SHIPPED:
return f"Your order is on the way! Tracking: {order.tracking_number}"
case OrderStatus.DELIVERED:
return "Your order has been delivered"
case OrderStatus.CANCELLED:
return "This order has been cancelled"

I can also use guard clauses for more complex conditions:

match_guard.py
def calculate_refund(order):
match order.status:
case OrderStatus.PENDING:
return order.total # Full refund
case OrderStatus.PAID if order.days_since_payment < 30:
return order.total * 0.9 # 10% restocking fee
case OrderStatus.PAID:
return order.total * 0.7 # 30% restocking fee
case OrderStatus.SHIPPED | OrderStatus.DELIVERED:
return 0 # No refund after shipping
case OrderStatus.CANCELLED:
return 0

Handling Unknown States

One pattern I use is handling unexpected states gracefully:

unknown_states.py
def process_order_safe(order):
match order.status:
case OrderStatus.PENDING:
handle_pending(order)
case OrderStatus.PAID:
handle_paid(order)
case OrderStatus.SHIPPED:
handle_shipped(order)
case OrderStatus.DELIVERED:
handle_delivered(order)
case OrderStatus.CANCELLED:
handle_cancelled(order)
case _:
# This catches any unknown states
raise RuntimeError(f"Unknown order status: {order.status}")

The case _: is like the else clause - it catches anything that doesn’t match the previous cases.

When to Use Enum vs Boolean

I use Enum when:

  • States are mutually exclusive (an order can’t be both pending and shipped)
  • I have more than 2 states (booleans work for exactly 2 states)
  • I want type checking
  • I want to document all valid states explicitly

I still use booleans when:

  • There are only 2 states (is_valid, is_empty)
  • The flags are truly independent (is_vip and is_first_time_buyer can both be true)

Here’s an example where booleans make sense:

boolean_correct.py
class Customer:
def __init__(self):
# These are independent flags - both can be true
self.is_vip = False
self.is_first_time_buyer = True
def get_discount(self):
discount = 0
if self.is_vip:
discount += 10
if self.is_first_time_buyer:
discount += 5
return discount

These booleans represent independent dimensions, not mutually exclusive states. Converting them to Enum would be over-engineering.

Complete Example: Order Processing

Let me put it all together with a complete order processing example:

complete_order.py
from enum import Enum, auto
from datetime import datetime
class OrderStatus(Enum):
PENDING = auto()
PAID = auto()
SHIPPED = auto()
DELIVERED = auto()
CANCELLED = auto()
class Order:
VALID_TRANSITIONS = {
OrderStatus.PENDING: {OrderStatus.PAID, OrderStatus.CANCELLED},
OrderStatus.PAID: {OrderStatus.SHIPPED, OrderStatus.CANCELLED},
OrderStatus.SHIPPED: {OrderStatus.DELIVERED},
OrderStatus.DELIVERED: set(),
OrderStatus.CANCELLED: set(),
}
def __init__(self, order_id: str, total: float):
self.order_id = order_id
self.total = total
self.status = OrderStatus.PENDING
self.tracking_number: str | None = None
self.created_at = datetime.now()
def transition_to(self, new_status: OrderStatus) -> None:
if not self.can_transition_to(new_status):
raise ValueError(
f"Invalid transition: {self.status} -> {new_status}"
)
self.status = new_status
def can_transition_to(self, new_status: OrderStatus) -> bool:
return new_status in self.VALID_TRANSITIONS[self.status]
def pay(self) -> None:
self.transition_to(OrderStatus.PAID)
def ship(self, tracking_number: str) -> None:
self.tracking_number = tracking_number
self.transition_to(OrderStatus.SHIPPED)
def deliver(self) -> None:
self.transition_to(OrderStatus.DELIVERED)
def cancel(self) -> None:
self.transition_to(OrderStatus.CANCELLED)
def __str__(self) -> str:
return f"Order({self.order_id}, {self.status.name}, ${self.total})"

Using the Order class:

using_order.py
# Create an order
order = Order("ORD-123", 99.99)
print(order) # Order(ORD-123, PENDING, $99.99)
# Process the order
order.pay()
print(order) # Order(ORD-123, PAID, $99.99)
order.ship("TRACK-ABC-123")
print(order) # Order(ORD-123, SHIPPED, $99.99)
order.deliver()
print(order) # Order(ORD-123, DELIVERED, $99.99)
# This will raise an error - can't cancel a delivered order
try:
order.cancel()
except ValueError as e:
print(e) # Invalid transition: DELIVERED -> CANCELLED

Summary

I refactored from boolean soup to Enum with match/case, and the benefits were immediate:

  1. Clarity - State names are self-documenting (OrderStatus.PENDING vs is_pending=True)
  2. Type safety - mypy and pyright catch invalid state assignments
  3. Explicit transitions - The valid state transitions are documented in code
  4. Clean pattern matching - match/case reads better than nested if/elif
  5. Single source of truth - One status field instead of multiple booleans

The key insight is that when you have mutually exclusive states, Enum is the right tool. When you have independent flags that can coexist, booleans still make sense.

For Python 3.10+, combining enum.Enum with match/case gives you clean, type-safe state management that’s easy to read and hard to break.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments