xstate-statemachine vs transitions
An honest comparison with pytransitions: the HierarchicalMachine extension vs native nesting, timers, persistence, typed context, XState JSON portability, a side-by-side order lifecycle, and when to choose transitions instead.
transitions is the most widely used state machine library in Python. You attach a Machine to any object, and it adds trigger methods and a state attribute. Extensions add nesting (HierarchicalMachine), async, locking, diagrams and timeouts. It is mature and flexible, and this page tries to be fair about that.
The table is generated from docs/_data/comparisons.json. Every row carries a source note, and corrections are welcome as PRs against that file. It was checked against transitions 0.9.3, 2026-11-07; see transitions for the current state.
Feature table
| Capability | xstate-statemachine | transitions | Source |
|---|---|---|---|
| Hierarchy (nested states) | Yes: compound states at any depth, XState v5 semantics | Yes, via the HierarchicalMachine extension |
transitions 0.9.3: transitions.extensions.HierarchicalMachine; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages |
| Parallel regions | Yes: type: parallel regions with a done.state join |
Yes, parallel states in HierarchicalMachine |
transitions 0.9.3: transitions.extensions.nesting; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages |
Timers (after) |
after in the chart; persisted as deadlines and fired by DueTimerScanner after a restart |
Timeout state extension (a thread per timed state; in memory only) |
transitions 0.9.3: transitions.extensions.states.Timeout; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages |
| Invoked services | invoke services with onDone / onError, retried by RetryPolicy |
DIY in callbacks | transitions 0.9.3: no invoke / onDone concept; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages |
| Actors / child machines | Spawned child machines (spawn, sendTo, sendParent) |
No | transitions 0.9.3: no child-machine concept; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages |
| Async | Two engines: asyncio Interpreter and threaded SyncInterpreter |
Yes: AsyncMachine |
transitions 0.9.3: transitions.extensions.AsyncMachine; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages |
| Locking / concurrent writers | OptimisticLock / PessimisticLock on every store; Django send() in transaction.atomic() + select_for_update() (or a version fence + send_with_retry); SQLAlchemy version_id_col |
LockedMachine: an in-process threading.Lock (re-entrant per thread); nothing across processes |
transitions 0.9.3: transitions.extensions.LockedMachine; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages |
| Versioning / migration | machine_version in every snapshot; SnapshotMigrator upgrades old snapshots on load |
DIY | transitions 0.9.3: no snapshot format to version; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages |
| Admin / UI | StatechartAdminMixin: permission-computed CSRF-protected transition buttons, confirm + reason, history inline, state filter, bulk actions; manage.py xsm_inspect / xsm_diagram |
No | transitions 0.9.3: no web or admin integration; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages |
| REST / DRF | StatechartViewSetMixin generates one DRF @action per event (plus drf-spectacular schema); FastAPI router, Litestar controller, Flask/Quart blueprints, Starlette registry helpers |
No | transitions 0.9.3: no web integration; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages |
| Audit trail | Django TransitionLog rows written in the SAME transaction as the state change (plus pre_transition / post_transition signals); transition log for SQLite, JSON lines, SQLAlchemy |
DIY with after_state_change callbacks |
transitions 0.9.3: callback hooks on Machine; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages |
| Visual editor | Stately editor: the chart is plain XState JSON | GraphMachine diagrams (Graphviz or Mermaid); no editor |
transitions 0.9.3: transitions.extensions.diagrams; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages |
| Typed context | [pydantic] context_model and one model per event, validated on send |
The model object’s attributes (no validation) | transitions 0.9.3: state is attached to any Python object; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages |
| Persistence | StatechartField on your Django model row (queryable __state / in_state()), or snapshots in SQLite, Redis, SQLAlchemy, Django ORM (DjangoStore), files |
DIY: pickle the model or store state |
transitions 0.9.3: MarkupMachine can dump the definition, not a runtime snapshot; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages |
| XState JSON portability | Reads XState v5 JSON (Stately export) and follows XState v5 statechart semantics; engine event names keep the v4 spelling | No (MarkupMachine has its own dict format) |
transitions 0.9.3: transitions.extensions.markup.MarkupMachine; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages |
📝 Nesting. In transitions, nesting is an extension: you switch from Machine to HierarchicalMachine, and nested states are addressed as "parent_child" strings. Here, compound and parallel states are the default model, with XState v5 semantics for entry and exit order, done.state events and history.
📝 Persistence. A transitions machine is its model object, so persisting it means pickling the object or storing model.state yourself, and timers (Timeout) live in a thread of the current process. Here, a snapshot includes the context and pending after deadlines, and the stores add locking, idempotency and a scanner that fires deadlines after a restart.
The same order lifecycle
A cart is checked out, then paid within 15 minutes or it expires, then shipped. It can be cancelled before payment.
Theirs: transitions
This is not executed here.
from transitions import Machine
from transitions.extensions.states import Timeout, add_state_features
@add_state_features(Timeout)
class TimeoutMachine(Machine):
pass
class Order:
items = 1
def has_items(self):
return self.items > 0
states = [
"cart",
{
"name": "awaiting_payment",
"timeout": 900,
"on_timeout": "expire",
},
"paid",
"shipped",
"expired",
"cancelled",
]
order = Order()
TimeoutMachine(
model=order,
states=states,
initial="cart",
transitions=[
{
"trigger": "checkout",
"source": "cart",
"dest": "awaiting_payment",
"conditions": "has_items",
},
{
"trigger": "pay",
"source": "awaiting_payment",
"dest": "paid",
},
{
"trigger": "ship",
"source": "paid",
"dest": "shipped",
},
{
"trigger": "expire",
"source": "awaiting_payment",
"dest": "expired",
},
{
"trigger": "cancel",
"source": ["cart", "awaiting_payment"],
"dest": "cancelled",
},
],
)
order.checkout()
order.pay()
order.ship()
# The 900 s timeout is a threading.Timer in this process; a restart loses it.
Ours
The same chart as JSON, run here with stub_logic, which satisfies the hasItems guard:
from xstate_statemachine import SyncInterpreter, create_machine, stub_logic
chart = {
"id": "order",
"initial": "cart",
"states": {
"cart": {
"on": {
"CHECKOUT": {
"target": "awaitingPayment",
"guard": "hasItems",
},
"CANCEL": "cancelled",
},
},
"awaitingPayment": {
"after": {
"900000": "expired",
}, # 15 minutes
"on": {
"PAY": "paid",
"CANCEL": "cancelled",
},
},
"paid": {
"on": {
"SHIP": "shipped",
},
},
"shipped": {
"type": "final",
},
"expired": {
"type": "final",
},
"cancelled": {
"type": "final",
},
},
}
order = SyncInterpreter(create_machine(chart, logic=stub_logic(chart))).start()
order.send("CHECKOUT")
order.send("PAY")
order.send("SHIP")
assert order.current_state_ids == {"order.shipped"}
snapshot = order.get_snapshot() # JSON: state, context and deadlines
order.stop()
The after deadline is part of snapshot. With a store and DueTimerScanner, it fires even when no process kept the order in memory. See Persistence.
When to choose transitions instead
- You want to add a state machine to an existing class with the least ceremony, and trigger methods such as
order.pay()read naturally in your code. - Your machine is mostly flat, lives in one process, and never needs to survive a restart mid-timer.
- You value a very large user base, a long track record and many answered questions.
- You want in-process Graphviz or Mermaid diagrams, and you do not need a visual editor.
- Raw speed on flat machines matters: it is the fastest library in our benchmark on flat scenarios (about 2x ours).
When to choose xstate-statemachine
- The chart must be portable XState JSON, edited in Stately and shared with a TypeScript frontend.
- You need durable timers, snapshots and cross-process locking, not only in-memory state.
- Nesting, parallel regions,
invokeand actors are the core of the design, not add-ons. - You want typed, validated context and events (the
[pydantic]extra) instead of free attributes on a model.
See Integration extras for the web and database integrations.
Porting? See Coming from transitions for a concept map and a worked port.
New to the library? Start with the integrations journey: pick your path, then a fifteen-minute tutorial.