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, invoke and 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.