What is it? #
The State pattern gives each status of an object its own class, and lets the object delegate behaviour to whichever state it currently holds.
The problem is the status conditional that appears in every method. if self.status == "draft" ... elif "paid" ... elif "shipped" repeated across eight methods, each slightly out of date with the others.
With the pattern, each state class knows what is allowed in that state and which state comes next. Invalid transitions are impossible rather than merely discouraged.
It is closely related to Strategy — both delegate to an interchangeable object. The difference is that a state decides the next state, while a strategy is chosen from outside and does not change itself.
Think of it like this #
A traffic light. Red, amber and green each know exactly what comes next, and there is no central controller holding a big conditional about every combination.
Each light state has one job and one successor, which is why the system is easy to reason about.
Simple example #
An order moves through draft, paid, shipped, delivered and cancelled. Each stage allows different actions: you can cancel a paid order but not a delivered one, and you can only ship an order that has been paid.
Code #
from abc import ABC, abstractmethod
class OrderState(ABC):
name: str
def pay(self, order): raise ValueError(f"cannot pay an order that is {self.name}")
def ship(self, order): raise ValueError(f"cannot ship an order that is {self.name}")
def deliver(self, order): raise ValueError(f"cannot deliver an order that is {self.name}")
def cancel(self, order): raise ValueError(f"cannot cancel an order that is {self.name}")
class Draft(OrderState):
name = "draft"
def pay(self, order):
order.state = Paid()
def cancel(self, order):
order.state = Cancelled()
class Paid(OrderState):
name = "paid"
def ship(self, order):
order.state = Shipped()
def cancel(self, order):
order.refund_issued = True # cancelling a paid order refunds
order.state = Cancelled()
class Shipped(OrderState):
name = "shipped"
def deliver(self, order):
order.state = Delivered()
class Delivered(OrderState):
name = "delivered" # terminal: everything raises
class Cancelled(OrderState):
name = "cancelled" # terminal
class Order:
def __init__(self, order_id):
self.id = order_id
self.state: OrderState = Draft()
self.refund_issued = False
@property
def status(self) -> str:
return self.state.name
def pay(self): self.state.pay(self)
def ship(self): self.state.ship(self)
def deliver(self): self.state.deliver(self)
def cancel(self): self.state.cancel(self)
order = Order("A-1")
print(order.status) # draft
order.pay()
order.ship()
print(order.status) # shipped
try:
order.cancel() # not allowed once shipped
except ValueError as exc:
print(exc) # cannot cancel an order that is shipped
order.deliver()
print(order.status) # delivered
draft ──pay──> paid ──ship──> shipped ──deliver──> delivered
│ │
cancel cancel (+refund)
│ │
└──> cancelled <┘
How it works #
OrderState provides a default for every action: refuse it, with a message naming the current state. Each concrete state then overrides only the transitions it permits.
That default-deny approach is the key design choice. Forgetting to handle an action means it is refused, which is the safe outcome. The opposite default would silently allow invalid transitions.
Draft.pay simply assigns a new state object. There is no table of allowed transitions to keep in sync — the knowledge lives in the state that owns it.
Paid.cancel shows state-specific side effects: cancelling after payment issues a refund, while cancelling a draft does not. In a conditional-based version, that difference is easy to miss.
Delivered and Cancelled override nothing, so every action raises. Terminal states cost one line each.
Order is thin. It holds the current state and forwards calls to it. The status property exposes the name for display and storage.
In a real system you would persist status as a string and rebuild the state object on load, with a small mapping from name to class.
Real-world use #
Order and payment workflows, support ticket lifecycles, content publishing (draft, review, published, archived), subscription status, and document approval flows are all state machines.
Getting this wrong has real consequences: shipping a cancelled order, refunding twice, or publishing a document that never passed review. Making invalid transitions impossible in code is far more reliable than remembering to check.
Many teams start with a status column and conditionals, which is fine for three states with two transitions. The pattern earns its place when transitions have their own rules and side effects.
There are also dedicated state machine libraries that add transition hooks, validation and diagram generation. They implement this same idea with more features around it.
Common mistakes #
- Allowing actions by default instead of refusing them, so gaps become bugs.
- Putting the transition table in the context class, which recreates the central conditional.
- Storing state objects directly in the database instead of the status name.
- Letting states hold business data, so switching state loses information.
- Applying it to a two-state flag where a boolean is clearer.
Practice #
Model a support ticket with states open, in-progress, waiting-on-customer, resolved and closed. Define which transitions are allowed, implement them with the State pattern, and make resolving a ticket record a timestamp. Then prove that a closed ticket cannot be moved back to open.