Python State Machines with Transitions — Deep Dive
Full Working Example
from transitions import Machine
class Order:
states = ["pending", "paid", "shipped", "delivered", "cancelled", "refunded"]
transitions = [
{"trigger": "pay", "source": "pending", "dest": "paid",
"conditions": ["is_payment_valid"], "after": "send_receipt"},
{"trigger": "ship", "source": "paid", "dest": "shipped",
"after": "notify_shipping"},
{"trigger": "deliver", "source": "shipped", "dest": "delivered",
"after": "request_review"},
{"trigger": "cancel", "source": ["pending", "paid"], "dest": "cancelled",
"before": "check_cancellation_window"},
{"trigger": "refund", "source": ["delivered", "shipped"], "dest": "refunded",
"conditions": ["within_refund_period"], "after": "process_refund"},
]
def __init__(self, order_id: str, total: float):
self.order_id = order_id
self.total = total
self.machine = Machine(
model=self,
states=Order.states,
transitions=Order.transitions,
initial="pending",
send_event=True, # pass EventData to callbacks
)
def is_payment_valid(self, event):
return event.kwargs.get("amount", 0) >= self.total
def within_refund_period(self, event):
from datetime import datetime, timedelta
delivered_at = getattr(self, "delivered_at", None)
if not delivered_at:
return True
return datetime.now() - delivered_at < timedelta(days=30)
def send_receipt(self, event):
print(f"Receipt sent for order {self.order_id}")
def notify_shipping(self, event):
print(f"Order {self.order_id} shipped")
def request_review(self, event):
from datetime import datetime
self.delivered_at = datetime.now()
print(f"Review requested for order {self.order_id}")
def check_cancellation_window(self, event):
# Could raise an exception to abort the transition
pass
def process_refund(self, event):
print(f"Refund of ${self.total} processed for order {self.order_id}")
# Usage
order = Order("ORD-001", 59.99)
print(order.state) # "pending"
print(order.may_pay()) # True
print(order.may_ship()) # False — not paid yet
order.pay(amount=59.99) # → paid
order.ship() # → shipped
order.deliver() # → delivered
print(order.is_delivered()) # True
Hierarchical (Nested) State Machines
The HierarchicalMachine extension supports nested states with NestedState:
from transitions.extensions import HierarchicalMachine
states = [
"idle",
{
"name": "active",
"children": [
"processing",
"waiting_input",
{
"name": "error",
"children": ["recoverable", "fatal"],
},
],
},
"shutdown",
]
transitions = [
{"trigger": "start", "source": "idle", "dest": "active_processing"},
{"trigger": "need_input", "source": "active_processing", "dest": "active_waiting_input"},
{"trigger": "resume", "source": "active_waiting_input", "dest": "active_processing"},
{"trigger": "fail", "source": "active_processing", "dest": "active_error_recoverable"},
{"trigger": "escalate", "source": "active_error_recoverable", "dest": "active_error_fatal"},
{"trigger": "recover", "source": "active_error_recoverable", "dest": "active_processing"},
{"trigger": "stop", "source": "active", "dest": "shutdown"}, # exits from any child
]
machine = HierarchicalMachine(
states=states,
transitions=transitions,
initial="idle",
)
Nested states let you define transitions on parent states that apply to all children — stop from active works whether you’re in active_processing, active_error_recoverable, or any other child state.
Async State Machines
For asyncio-based applications:
from transitions.extensions.asyncio import AsyncMachine
class AsyncOrder:
states = ["pending", "processing", "complete", "failed"]
def __init__(self):
self.machine = AsyncMachine(
model=self,
states=self.states,
initial="pending",
transitions=[
{"trigger": "process", "source": "pending", "dest": "processing",
"after": "run_processing"},
{"trigger": "complete", "source": "processing", "dest": "complete"},
{"trigger": "fail", "source": "processing", "dest": "failed"},
],
)
async def run_processing(self):
import asyncio
await asyncio.sleep(1) # simulate async work
print("Processing complete")
# Usage
import asyncio
async def main():
order = AsyncOrder()
await order.process() # triggers are now awaitable
print(order.state) # "processing"
asyncio.run(main())
Persistence: Saving and Restoring State
State machines are in-memory by default. For persistence, serialize the state:
import json
class PersistentOrder(Order):
def save(self) -> str:
return json.dumps({
"order_id": self.order_id,
"total": self.total,
"state": self.state,
})
@classmethod
def load(cls, data: str) -> "PersistentOrder":
d = json.loads(data)
order = cls(d["order_id"], d["total"])
# Force state without triggering transitions
order.machine.set_state(d["state"])
return order
Django Integration
from django.db import models
from transitions import Machine
class OrderModel(models.Model):
STATES = ["pending", "paid", "shipped", "delivered", "cancelled"]
order_id = models.CharField(max_length=50, unique=True)
total = models.DecimalField(max_digits=10, decimal_places=2)
state = models.CharField(max_length=20, default="pending")
class Meta:
db_table = "orders"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.machine = Machine(
model=self,
states=self.STATES,
initial=self.state,
transitions=[
{"trigger": "pay", "source": "pending", "dest": "paid",
"after": "save_state"},
{"trigger": "ship", "source": "paid", "dest": "shipped",
"after": "save_state"},
],
model_attribute="state",
)
def save_state(self):
self.save(update_fields=["state"])
Diagram Generation
transitions can generate state diagrams using Graphviz:
from transitions.extensions import GraphMachine
class VisualOrder:
pass
machine = GraphMachine(
model=VisualOrder(),
states=["pending", "paid", "shipped", "delivered"],
transitions=[
{"trigger": "pay", "source": "pending", "dest": "paid"},
{"trigger": "ship", "source": "paid", "dest": "shipped"},
{"trigger": "deliver", "source": "shipped", "dest": "delivered"},
],
initial="pending",
show_conditions=True,
show_state_attributes=True,
)
# Generate PNG
machine.get_graph().draw("order_workflow.png", prog="dot")
This is invaluable for documentation — the diagram is always in sync with the code because it’s generated from the same transition definitions.
Thread Safety
The default Machine is not thread-safe. For concurrent access:
from transitions.extensions import LockedMachine
machine = LockedMachine(
model=my_model,
states=states,
transitions=transitions,
initial="idle",
)
# All trigger calls are now mutex-protected
LockedMachine wraps each trigger call in a threading.Lock. For high-contention scenarios, consider per-model locking or event queuing instead.
Custom Transition Validation
Beyond simple conditions, you can implement complex validation by overriding transition behavior:
from transitions import Machine, MachineError
class StrictOrder:
def __init__(self):
self.approved_by = None
self.amount = 0
self.machine = Machine(
model=self,
states=["draft", "pending_approval", "approved", "executed"],
transitions=[
{
"trigger": "submit",
"source": "draft",
"dest": "pending_approval",
"conditions": ["has_valid_amount"],
"unless": ["is_duplicate"],
},
{
"trigger": "approve",
"source": "pending_approval",
"dest": "approved",
"conditions": ["has_approver", "approver_has_authority"],
},
],
initial="draft",
send_event=True,
)
def has_valid_amount(self, event):
return 0 < self.amount <= 1_000_000
def is_duplicate(self, event):
return False # check against database
def has_approver(self, event):
return event.kwargs.get("approver") is not None
def approver_has_authority(self, event):
approver = event.kwargs.get("approver")
if self.amount > 10_000:
return approver.role == "director"
return approver.role in ("manager", "director")
The unless parameter inverts conditions — the transition proceeds only if is_duplicate returns False.
Testing State Machines
import pytest
from transitions import MachineError
def test_valid_order_flow():
order = Order("TEST-001", 29.99)
assert order.state == "pending"
order.pay(amount=29.99)
assert order.state == "paid"
order.ship()
assert order.state == "shipped"
def test_cannot_ship_unpaid_order():
order = Order("TEST-002", 29.99)
with pytest.raises(MachineError):
order.ship() # Can't ship from "pending"
def test_insufficient_payment_blocked():
order = Order("TEST-003", 100.00)
order.pay(amount=50.00) # Condition fails
assert order.state == "pending" # State unchanged
def test_may_checks():
order = Order("TEST-004", 29.99)
assert order.may_pay() is True
assert order.may_ship() is False
order.pay(amount=29.99)
assert order.may_pay() is False
assert order.may_ship() is True
Alternatives to transitions
| Library | Approach | Best For |
|---|---|---|
| transitions | Class-based, mixin-style | General-purpose, most popular |
| python-statemachine | Declarative class syntax | Clean API, good docs |
| sismic | Statechart interpreter (SCXML) | Complex hierarchical machines |
| xstate-python | Port of XState | JavaScript ecosystem familiarity |
| enum + match | DIY with Python 3.10+ | Minimal dependencies |
Performance Notes
- Transition dispatch: ~50,000 transitions/sec (with callbacks)
- Without callbacks: ~200,000 transitions/sec
- Memory per machine: ~2-5 KB depending on state/transition count
- The overhead is negligible for business logic workflows but inappropriate for per-packet or per-frame processing
One thing to remember: The transitions library turns implicit state logic (scattered if/else chains) into explicit, testable, visualizable workflow definitions — and its extensions for async, hierarchy, locking, and diagram generation cover most production needs without leaving the ecosystem.
See Also
- Python Event Emitter Patterns How Python programs shout 'something happened!' so other parts of the code can react — like a school bell that tells everyone it's recess.
- Python Observer Vs Pubsub Two ways Python code can share news — one is like telling your friends directly, the other is like posting on a bulletin board for anyone to read.
- Python Rxpy Reactive Programming How RxPY lets Python code react to streams of data the way a news ticker reacts to breaking stories — automatically and in real time.
- Ci Cd Why big apps can ship updates every day without turning your phone into a glitchy mess — CI/CD is the behind-the-scenes quality gate and delivery truck.
- Containerization Why does software that works on your computer break on everyone else's? Containers fix that — and they're why Netflix can deploy 100 updates a day without the site going down.