xstate-statemachine vs python-statemachine

An honest comparison with python-statemachine: its declarative class API and diagrams, statecharts on both sides, timers, persistence, locking, XState JSON, a side-by-side order lifecycle, and when to choose python-statemachine instead.

python-statemachine has a clean, declarative, very Pythonic API: states and transitions are class attributes, and callbacks are found by naming convention. Since version 3 its StateChart supports compound, parallel and history states and invoke, and it has excellent diagram and documentation tooling. It is a real alternative, and on ergonomics it is ahead of us.

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 python-statemachine 3.2.2, 2026-11-07; see python-statemachine for the current state.

Feature table

Capability xstate-statemachine python-statemachine Source
Hierarchy (nested states) Yes: compound states at any depth, XState v5 semantics Yes: State.Compound in StateChart python-statemachine 3.2.2: statemachine.State.Compound; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages
Parallel regions Yes: type: parallel regions with a done.state join Yes: State.Parallel python-statemachine 3.2.2: statemachine.State.Parallel; 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 send(..., delay=) and contrib.timeout (in memory only) python-statemachine 3.2.2: StateMachine.send(delay=), statemachine.contrib.timeout; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages
Invoked services invoke services with onDone / onError, retried by RetryPolicy Yes: invoke= on a state, cancelled on exit python-statemachine 3.2.2: statemachine.invoke; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages
Actors / child machines Spawned child machines (spawn, sendTo, sendParent) Child machines can be invoked; no spawn / actor system python-statemachine 3.2.2: statemachine.invoke accepts a StateChart class; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages
Async Two engines: asyncio Interpreter and threaded SyncInterpreter Yes: async callbacks run on an async engine python-statemachine 3.2.2: statemachine.engines; 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 DIY python-statemachine 3.2.2: no store, so no cross-process locking; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages
Versioning / migration machine_version in every snapshot; SnapshotMigrator upgrades old snapshots on load DIY python-statemachine 3.2.2: 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 python-statemachine 3.2.2: a Django MachineMixin, but no 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 python-statemachine 3.2.2: 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 listeners python-statemachine 3.2.2: observer / listener callbacks; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages
Visual editor Stately editor: the chart is plain XState JSON Diagrams (Graphviz or Mermaid) and a Sphinx extension; no editor python-statemachine 3.2.2: statemachine.contrib.diagram (Graphviz via pydot, renderers/mermaid.py); ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages
Typed context [pydantic] context_model and one model per event, validated on send The machine or model’s attributes; strong IDE typing of states and events python-statemachine 3.2.2: declarative class API; 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 MachineMixin stores the state on a model field python-statemachine 3.2.2: statemachine.mixins.MachineMixin; 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 Reads its own JSON / YAML / SCXML documents, not XState JSON python-statemachine 3.2.2: statemachine.io (json, yaml, scxml); ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages

📝 Where the two really differ. Both libraries implement statecharts. The difference is where the machine lives and what happens around it. python-statemachine defines the machine as a Python class and keeps it in memory. xstate-statemachine treats the chart as portable XState JSON, and its surrounding pieces (stores, locks, persisted deadlines, web routers) assume that the machine is loaded, changed and saved on each request.

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: python-statemachine

This is not executed here.

from statemachine import State, StateChart
from statemachine.contrib.timeout import timeout

class Order(StateChart):
    cart = State(initial=True)
    awaiting_payment = State(invoke=timeout(900, on="expire"))
    paid = State()
    shipped = State(final=True)
    expired = State(final=True)
    cancelled = State(final=True)

    checkout = cart.to(awaiting_payment, cond="has_items")
    pay = awaiting_payment.to(paid)
    ship = paid.to(shipped)
    expire = awaiting_payment.to(expired)
    cancel = cart.to(cancelled) | awaiting_payment.to(cancelled)

    items = 1
    def has_items(self):
        return self.items > 0

order = Order()
order.send("checkout"); order.send("pay"); order.send("ship")
# The timeout runs in this process; persisting it is up to you.

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()
for event in ("CHECKOUT", "PAY", "SHIP"):
    order.send(event)
assert order.current_state_ids == {"order.shipped"}
order.stop()

If you prefer classes to JSON, the Pythonic API builds the same chart from Python, and it stays exportable to XState JSON.

When to choose python-statemachine instead

  • You want the nicest declarative class API in Python, with strong IDE support for states and events, and you do not need JSON.
  • Diagrams and generated documentation matter to you: its Graphviz and Mermaid output and its Sphinx extension are excellent.
  • The machine lives in one process, and persistence is “store the current state on a model field” (MachineMixin).
  • You want SCXML or YAML import rather than XState JSON.

When to choose xstate-statemachine

  • The chart must come from XState / Stately (the library reads XState v5 JSON and follows v5 statechart semantics; event names for invoke/after/done.state keep the v4 spelling), or run the same in a TypeScript frontend and a Python backend.
  • You need durable after deadlines, snapshots, cross-process locking and idempotency, not only in-memory state.
  • You want the web and database plumbing already done: routers and blueprints for FastAPI, Starlette, Litestar, Flask and Quart, a Django model field with admin and DRF/Channels surfaces, and a SQLAlchemy mixin.
  • You need an actor system (spawned children that message each other), not only invoked child machines.

See Integration extras for everything that ships today.

Porting? See Coming from python-statemachine 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.