Skip to main content

⚙️ XState-StateMachine

Statecharts for Python. Run your XState JSON — unmodified.


PyPI Python CI Tests Coverage Dependencies License


The only Python library that runs XState / Stately.ai machine definitions as-is.

Design a flow once in the visual editor — ship the same JSON to your React frontend and your Python backend. Async and sync interpreters. Zero dependencies.


Install · 60-Second Start · Why · Cookbook · API · Docs


🗺️ Find your way

Section What you get
🚀 Install · 60-Second Start Running in under a minute
🧠 Why a Statechart · Mental Model The three bugs this deletes
🔗 XState Interop One JSON, React and Python
🧩 Context · Guards · Actions The building blocks
🔌 Services · Timers Async work and time
🌳 Nested · Parallel · History Real-world hierarchy
🤖 Actors · Persistence Systems of machines
🔍 Introspection · Pure API Observe and test
🐍 Pythonic API No JSON required
🛠️ CLI Tool Generate, inspect, simulate, diagram — zero deps
📚 Cookbook · FAQ Copy-paste recipes
🏭 Production · API Reference · Troubleshooting Failure semantics, every kwarg, every error

✨ What you get

🔗 Real XState interop

Run Stately.ai JSON unmodified. Not "inspired by" — the same file your frontend uses.

⚡ Async and sync

Interpreter for asyncio, SyncInterpreter for scripts, Django, and CLIs. Same machine, same semantics.

📦 Zero dependencies

Pure standard library. Nothing to audit, nothing to conflict, Python 3.9 → 3.14.

🌳 Full statechart spec

Nested, parallel, history, guards, timers, invoke, actors — not just a flat enum with if statements.

🧪 Testable by design

A pure, interpreter-free API returns the next state as a value. No mocks, no event loop, no sleeping.

🛠️ A terminal toolkit

xsm turns JSON into typed Python — and proves the result rebuilds your machine before writing it. It also inspects, simulates, diagrams and documents your machines, with an interactive launcher on a terminal.

🛡️ Production hardening

Per-machine actionErrorPolicy, strict events and strict_config turn silent failure into a raised, observable error. A runaway self-send chain is cut and recorded (chain_trips); a typo'd config key names itself and its path.

⏱️ Deterministic tests

Inject SimulatedClock and after fires on your schedule, not the wall clock. send(wait=True) returns a Receipt — no polling.

📮 Bounded inbox

max_queue_size + overflow_policy (RAISE / BLOCK / DROP_NEWEST) cap memory under a slow consumer instead of growing the queue forever.


🚀 Install

pip install xstate-statemachine

That's the whole story. Zero runtime dependencies — pure standard library, Python 3.9 → 3.14.

Releases are published from GitHub Actions through PyPI Trusted Publishing (no long-lived token) and carry PEP 740 build provenance attestations binding each wheel and sdist to the exact run, commit and workflow that built it. To verify an artefact instead of trusting a diff you ran yourself:

pip install pypi-attestations
pypi-attestations verify pypi --repository https://github.com/basiltt/xstate-statemachine \
  pypi:xstate_statemachine-0.10.1-py3-none-any.whl   # prints "OK: <file>" on success
xsm info          # verify the install
xsm update        # later: upgrade to the latest release

Windows, xsm.exe blocked by an Application Control policy? That is pip's unsigned launcher stub being refused by WDAC / AppLocker, not the package. Run python -m xstate_statemachine setup once: it parks the blocked launcher and installs a batch shim, after which xsm works normally (re-run after pip install --upgrade; --undo reverts). python -m xstate_statemachine … always works too. Details: CLI → Windows.

Using the code generator and want its output line-wrapped to match your linter? That needs black and isort, which stay optional so the core install keeps its zero-dependency promise:

pip install "xstate-statemachine[format]"

Without them, generated code is still valid and still faithful to your machine — just not reformatted.

uv · poetry · pipx
uv add xstate-statemachine
poetry add xstate-statemachine
pipx install xstate-statemachine     # if you only want the `xsm` CLI

⚡ The 60-Second Start

Copy, paste, run. No async, no setup, no config files.

from xstate_statemachine import create_machine, SyncInterpreter

machine = create_machine({
    "id": "toggle",
    "initial": "inactive",
    "states": {
        "inactive": {"on": {"TOGGLE": "active"}},
        "active":   {"on": {"TOGGLE": "inactive"}},
    },
})

light = SyncInterpreter(machine).start()

print(light.current_state_ids)      # {'toggle.inactive'}
light.send("TOGGLE")
print(light.current_state_ids)      # {'toggle.active'}
light.send("BANANA")                # not a legal event here
print(light.current_state_ids)      # {'toggle.active'}  ← ignored, not crashed

You just declared the complete set of legal states and the only legal moves between them. TOGGLE advances the machine. BANANA is ignored — not raised, not silently mishandled. Ignored, because the current state does not accept it.

That single property is what kills a whole category of bug.


🧠 Why a Statechart?

Every non-trivial flow starts as a few booleans. Then it grows.

😖 Boolean soup

if is_loading and not is_error:
    ...
elif is_error and retry_count < 3:
    ...
elif is_authenticated and not is_loading:
    ...

Four booleans = 16 combinations. You handled maybe six. The other ten are reachable — and one of them is is_loading=True, is_error=True, is_success=True.

Nothing stops it. Nothing warns you. It just happens in production at 3am.

😌 A statechart

"states": {
    "idle":    {"on": {"FETCH": "loading"}},
    "loading": {"on": {"OK": "done",
                       "ERR": "failed"}},
    "failed":  {"on": {"RETRY": "loading"}},
    "done":    {"type": "final"},
}

Four states = exactly four possibilities. The impossible ones cannot be constructed, because you never wrote a path to them.

Illegal events in the current state are simply ignored.

The three bugs this eliminates

Bug How booleans cause it How a statechart prevents it
🕳️ Impossible states is_loading and is_error both true The machine is in exactly one state per region
👻 Zombie callbacks A late API response fires after the user cancelled The event isn't handled in cancelled, so it's discarded
🔁 Double submission A second click before the first finishes submitting has no SUBMIT handler — the click does nothing

The rule — a machine is in exactly one state per region. Parallel states have multiple regions, so multiple states are active at once, which is why current_state_ids returns a set.


🔗 The Part No Other Python Library Does

Your frontend team models a checkout flow in Stately.ai. They export checkout.json and wire it into React with XState.

You take that exact file — unedited — and run it in Python:

import json
from xstate_statemachine import create_machine, MachineLogic, SyncInterpreter

with open("checkout.json") as f:          # ← straight from the frontend repo
    config = json.load(f)

machine = create_machine(config, logic=MachineLogic(
    actions={"charge_card": charge_card},   # you supply the Python side
    guards={"has_stock": has_stock},
))

checkout = SyncInterpreter(machine).start()

One definition. Two runtimes. The UI cannot render a step your backend considers illegal, because there is only one source of truth for what the steps are.

How compatible is "compatible"? (real numbers)

The test suite includes 104 real-world machines exported from Stately.ai. 103 of them parse structurally unmodified. The single exception has no top-level states key at all — it isn't a well-formed machine.

Both XState v4 (cond) and v5 (guard) transition spellings are accepted, so machines from either generation work.

What is not supported: JS/TS action implementations embedded in the JSON. Those are code, not data — you supply the Python equivalents via MachineLogic, which is the whole point of the separation.

Note — this library implements the SCXML transition-selection algorithm (the W3C standard XState itself follows). That is what makes nested and parallel-region behaviour match XState rather than merely resemble it. It does not import or export .scxml files.


🧩 The Mental Model

Six concepts. That's the entire library.

Concept What it is In JSON
State A named mode the machine can be in "states": {"idle": {}}
Event A message you send in interp.send("FETCH")
Transition "In state X, event E moves to Y" "on": {"FETCH": "loading"}
Context Everything that isn't a state — the data "context": {"retries": 0}
Guard A condition that must hold for a transition {"target": "x", "guard": "isReady"}
Action A side effect that fires during a transition {"target": "x", "actions": ["save"]}

The split that matters: state is where you are, context is what you know. retries is context. retrying is a state. Getting that boundary right is 90% of good statechart design.

stateDiagram-v2
    direction LR
    [*] --> idle
    idle --> loading: FETCH
    loading --> done: onDone
    loading --> failed: onError
    failed --> loading: RETRY
    done --> [*]

💾 Context — The Machine's Memory

Context is a plain dict. Update it declaratively with assign:

from xstate_statemachine import create_machine, SyncInterpreter, assign

cart = SyncInterpreter(create_machine({
    "id": "cart",
    "initial": "shopping",
    "context": {"items": 0, "total": 0.0},
    "states": {
        "shopping": {
            "on": {
                "ADD_ITEM": {"actions": assign({
                    "items": lambda a: a["context"]["items"] + 1,
                    "total": lambda a: a["context"]["total"] + a["event"].payload["price"],
                })},
                "CLEAR": {"actions": assign(lambda a: {"items": 0, "total": 0.0})},
            }
        }
    },
})).start()

cart.send("ADD_ITEM", price=9.99)
cart.send("ADD_ITEM", price=5.01)
print(cart.context)          # {'items': 2, 'total': 15.0}

assign takes either a dict of per-key updaters or a single callable returning a partial dict. Each updater receives one mapping with "context" and "event" keys.

Tip — keyword arguments to send() land in event.payload. send("ADD_ITEM", price=9.99) → a["event"].payload["price"].


🛡️ Guards — Conditional Transitions

A guard is a pure function returning bool. List transitions in priority order; the first whose guard passes wins.

from xstate_statemachine import create_machine, SyncInterpreter, MachineLogic

config = {
    "id": "atm",
    "initial": "idle",
    "context": {"balance": 100, "frozen": False},
    "states": {
        "idle": {
            "on": {
                "WITHDRAW": [
                    {"target": "approved", "guard": {
                        "type": "and",
                        "params": {"guards": [
                            "hasFunds",
                            {"type": "not", "params": {"guards": ["isFrozen"]}},
                        ]},
                    }},
                    {"target": "denied"},          # fallback — no guard
                ]
            }
        },
        "approved": {}, "denied": {},
    },
}

logic = MachineLogic(guards={
    "hasFunds": lambda ctx, e: ctx["balance"] >= e.payload.get("amount", 0),
    "isFrozen": lambda ctx, e: ctx["frozen"],
})

atm = SyncInterpreter(create_machine(config, logic=logic)).start()
atm.send("WITHDRAW", amount=50)
print(atm.current_state_ids)      # {'atm.approved'}

Composite guards — and, or, not nest arbitrarily via params.guards. There's also stateIn for "only if some other region is in state X":

{"guard": {"type": "stateIn", "params": {"state": "auth.loggedIn"}}}

Note — guards must be pure. They can be evaluated more than once, and a guard with side effects will surprise you. Put side effects in actions.

XState v4 compatibility

cond (v4) and guard (v5) are both accepted, so machines from either XState generation work without editing.


🎬 Actions — Side Effects

Actions fire during a transition, or on entering/leaving a state.

"states": {
    "loading": {
        "entry": ["showSpinner"],          # on the way in
        "exit":  ["hideSpinner"],          # on the way out
        "on": {"CANCEL": {"target": "idle", "actions": ["logCancel"]}},
    }
}

Order is guaranteed: exit actions → transition actions → entry actions.

Built-in action creators

You rarely need to hand-write these — import them and go:

Creator Does
assign Update context
log Structured log line
raise_ Send an event to this machine
send_to Send to another actor by id or systemId
send_parent Send to the machine that spawned you
choose Run the first action list whose guard passes
pure Compute actions from context at runtime
enqueue_actions Imperatively queue actions in a callback
spawn_child / stop_child Start / stop a child actor
cancel Cancel a delayed send_to
emit Emit an event to external subscribers
escalate Raise an error to the parent
forward_to Forward the current event to another actor

Note — if an action raises, the error is logged and contained. The transition still completes and the interpreter keeps running; one buggy side effect can't take down a long-lived machine. To react to a failure, use the on_action_error plugin hook or record it on context and guard on it.

Worked examples — the ones that aren't obvious from the name

choose — first passing guard wins. The declarative form of if/elif/else:

"on": {"GO": {"target": "b", "actions": [choose([
    {"guard": "isBig",   "actions": [assign({"label": lambda a: "big"})]},
    {"guard": "isSmall", "actions": [assign({"label": lambda a: "small"})]},
    {"actions": [assign({"label": lambda a: "other"})]},   # no guard = default
])]}}

With context = {"n": 7} and guards isBig = n > 10, isSmall = n < 5, this falls through to label = "other".

pure — decide the action list at runtime. Return actions, or nothing:

"actions": [pure(lambda a:
    [assign({"n": lambda b: b["context"]["n"] * 2})]
    if a["context"]["n"] < 10 else []
)]

Starting from n = 2, four sends give 2 → 4 → 8 → 16, then it stops doubling because the guard inside pure returns an empty list.

raise_ — feed an event back to this machine. Useful for expressing "and then immediately…" without a fake external trigger:

"actions": [raise_("VALIDATE")]

send_to / send_parent — talk to other actors. Delayed sends are cancellable:

"actions": [send_to("timer", "TICK", delay=1000, send_id="tick")]
# elsewhere
"actions": [cancel("tick")]

emit — publish outward without coupling. The machine says what happened; subscribers decide what to do:

"actions": [emit("saved")]                        # or emit({"type": "saved", "id": 7})
interpreter.on("saved", lambda ev: analytics.track(ev.type))

🔌 Services & Invoke

invoke runs an async or sync callable when a state is entered, and routes its result back into the machine as onDone / onError. This is how you do I/O.

import asyncio
from xstate_statemachine import (
    create_machine, Interpreter, MachineLogic, assign, wait_for,
)

config = {
    "id": "fetch",
    "initial": "idle",
    "context": {"user": None, "error": None},
    "states": {
        "idle": {"on": {"FETCH": "loading"}},
        "loading": {
            "invoke": {
                "src": "fetchUser",
                "onDone": {"target": "success",
                           "actions": assign({"user": lambda a: a["event"].data})},
                "onError": {"target": "failure",
                            "actions": assign({"error": lambda a: str(a["event"].data)})},
            }
        },
        "success": {"type": "final"},
        "failure": {"on": {"RETRY": "loading"}},
    },
}

async def fetch_user(interpreter, ctx, event):
    await asyncio.sleep(0.01)
    return {"id": 1, "name": "Ada"}

async def main():
    machine = create_machine(config, logic=MachineLogic(services={"fetch_user": fetch_user}))
    svc = await Interpreter(machine).start()

    await svc.send("FETCH")
    await wait_for(svc, lambda s: s.matches("fetch.success"), timeout=2)

    print(svc.context["user"])        # {'id': 1, 'name': 'Ada'}
    await svc.stop()

asyncio.run(main())
  • Success → onDone, with the return value on event.data
  • Failure → onError, with the exception object on event.data
  • Leaving the state cancels the service automatically — no zombie tasks

Tip — use wait_for (async) or wait_for_sync rather than asyncio.sleep() guesses. It polls a predicate with a real timeout, so tests stay fast and never flake.


⏱️ Timers & Delayed Transitions

after fires a transition if the machine is still in that state when the timer elapses. Leave early and the timer is cancelled for you.

"connecting": {
    "after": {5000: "timedOut"},          # 5000 ms
    "on": {"OPEN": "online"},             # ...unless we connect first
}

Name your delays to keep magic numbers out of the config — and to compute them at runtime, which is exactly how you express exponential backoff:

logic = MachineLogic(delays={
    "TIMEOUT": 60_000,
    "BACKOFF": lambda ctx, e: 2 ** ctx["attempt"] * 1000,   # 1s, 2s, 4s, 8s…
})
"retrying": {"after": {"BACKOFF": "loading"}}

🌳 Nested & Parallel States

Nested (compound) states

Group related substates so shared transitions live in one place:

"states": {
    "authenticated": {
        "initial": "browsing",
        "on": {"LOGOUT": "loggedOut"},     # ← applies to EVERY substate
        "states": {
            "browsing":  {"on": {"CHECKOUT": "paying"}},
            "paying":    {"on": {"DONE": "confirmed"}},
            "confirmed": {},
        },
    },
    "loggedOut": {},
}

LOGOUT works from browsing, paying, and confirmed. Write it once.

Parallel states — concurrent regions

Regions run independently. onDone fires exactly once, when all of them reach a final state — fan-out and fan-in with no bookkeeping:

from xstate_statemachine import create_machine, SyncInterpreter

ci = SyncInterpreter(create_machine({
    "id": "ci",
    "initial": "running",
    "states": {
        "running": {
            "type": "parallel",
            "onDone": "deployed",
            "states": {
                "build": {"initial": "b", "states": {
                    "b": {"on": {"BUILD_OK": "done"}}, "done": {"type": "final"}}},
                "lint":  {"initial": "l", "states": {
                    "l": {"on": {"LINT_OK": "done"}},  "done": {"type": "final"}}},
            },
        },
        "deployed": {},
    },
})).start()

print(sorted(ci.current_state_ids))   # ['ci.running.build.b', 'ci.running.lint.l']
ci.send("BUILD_OK")
print(sorted(ci.current_state_ids))   # ['ci.running.build.done', 'ci.running.lint.l']
ci.send("LINT_OK")
print(sorted(ci.current_state_ids))   # ['ci.deployed']   ← fan-in fired

This is where current_state_ids returning a set finally makes sense.


🕰️ History & Final States

History remembers where you were, so an interruption doesn't lose progress — the classic "resume the wizard where the user left off":

"states": {
    "wizard": {
        "initial": "step1",
        "states": {
            "step1": {}, "step2": {}, "step3": {},
            "hist": {"type": "history", "history": "shallow"},   # or "deep"
        },
    },
    "helpModal": {"on": {"CLOSE": "wizard.hist"}},    # ← back to the exact step
}

Final states mark completion. A final state in a compound state fires its parent's onDone; a top-level final state stops the machine and can produce output.


🤖 The Actor Model

Machines can spawn other machines. Each child gets its own state, context and lifecycle — a supervision tree, not a callback pile. Register a child under a systemId and any machine in the system can address it by name.

from xstate_statemachine import create_machine, SyncInterpreter, MachineLogic

# The child machine — an independent actor with its own context.
worker = {
    "id": "worker",
    "initial": "idle",
    "context": {"jobs": 0},
    "states": {"idle": {"on": {"JOB": {"target": "idle", "actions": ["count"]}}}},
}
worker_logic = MachineLogic(actions={
    "count": lambda i, ctx, e, a: ctx.__setitem__("jobs", ctx["jobs"] + 1),
})

parent = {
    "id": "super",
    "initial": "up",
    "context": {},
    "states": {
        "up": {
            "entry": [{"type": "spawnChild",
                       "params": {"src": "worker", "id": "w1", "systemId": "pool"}}],
            "on": {"DISPATCH": {"actions": [
                {"type": "sendTo", "params": {"to": "pool", "event": {"type": "JOB"}}}
            ]}},
        }
    },
}

logic = MachineLogic(services={
    "worker": lambda i, ctx, e: create_machine(worker, logic=worker_logic),
})

sup = SyncInterpreter(create_machine(parent, logic=logic)).start()
print(list(sup.system.get_all()))          # ['pool']

sup.send("DISPATCH")
sup.send("DISPATCH")
print(sup.system.get("pool").context["jobs"])   # 2

Children talk back with send_parent, escalate failures with escalate, and are torn down with stop_child — or automatically when the parent stops.

Good fit for: LLM agent orchestration (each tool call a supervised child), connection pools, per-user session machines, job workers.


💾 Persistence — Snapshots

Serialize a running machine to JSON, store it anywhere, rebuild it later. Long-running flows survive deploys and restarts.

from xstate_statemachine import create_machine, SyncInterpreter

job = SyncInterpreter(create_machine(config)).start()
job.send("NEXT")

snapshot = job.get_snapshot()      # a JSON string → Redis, Postgres, a file…
job.stop()

# …new process, hours later…
resumed = SyncInterpreter.from_snapshot(snapshot, create_machine(config))
print(resumed.current_state_ids)   # {'job.step2'}   ← exactly where it left off
resumed.send("NEXT")

State, context and systemId registrations all round-trip. get_persisted_snapshot() gives you the dict form if you'd rather store structured data.

Note — pending after timers are not resumed by a restore. A machine saved while waiting on a 30-minute timeout will wait indefinitely after restore. If a deadline must survive a restart, store it in context and re-arm it yourself on resume.

Every snapshot carries an envelope (version, machine_id, machine_hash) so from_snapshot() refuses a structurally different machine with SnapshotDriftError instead of silently resuming into it; pass verify_machine_hash=False after a deliberate migration. Invokes are not restarted either — pass from_snapshot(..., restart_services=True) to re-invoke every service pending_invocations() reports, or leave them stopped and re-trigger manually.


🧪 The Pure API — No Interpreter

Sometimes you want to ask "what would happen if…" without running anything. The pure API is a set of side-effect-free functions over immutable snapshots — ideal for tests, planning, and "preview the next step" UI.

from xstate_statemachine import (
    MachineLogic, create_machine, initial_transition, pure_transition,
)

machine = create_machine({
    "id": "fetch",
    "initial": "idle",
    "states": {
        "idle":    {"on": {"FETCH": {"target": "loading", "actions": "logStart"}}},
        "loading": {"on": {"OK": "done"}},
        "done":    {"type": "final"},
    },
}, logic=MachineLogic())      # 📝 no implementations needed — nothing runs

snapshot, entry_actions = initial_transition(machine)
next_snapshot, actions = pure_transition(machine, snapshot, "FETCH")

print(snapshot.state_ids)         # {'fetch.idle'}
print(next_snapshot.state_ids)    # {'fetch.loading'}
print([a.type for a in actions])  # ['logStart'] — what WOULD have run

Both functions return (snapshot, actions). If you only want the next state, get_next_snapshot(machine, snapshot, "FETCH") returns the snapshot alone.

A PureSnapshot exposes state_ids, context, status, output, configuration and matches(). No timers start. No services fire. Nothing mutates.


🔍 Introspection & Plugins

A running machine can answer questions about itself — which is what lets you drive a UI from it without duplicating its logic in your view layer.

from xstate_statemachine import (
    create_machine, MachineLogic, SyncInterpreter, assign, emit,
)

editor = create_machine({
    "id": "editor",
    "initial": "clean",
    "context": {"saves": 0},
    "states": {
        "clean":  {"tags": ["idle"],
                   "meta": {"hint": "Nothing to save"},
                   "on": {"EDIT": "dirty"}},
        "dirty":  {"tags": ["unsaved"], "on": {"SAVE": "saving"}},
        "saving": {"tags": ["unsaved", "busy"],
                   "on": {"OK": {"target": "clean", "actions": [
                       assign({"saves": lambda a: a["context"]["saves"] + 1}),
                       emit("saved"),
                   ]}}},
    },
}, logic=MachineLogic())

ed = SyncInterpreter(editor).start()

ed.matches("editor.clean")   # True  — nested paths work: "a.b.c"
ed.can("EDIT")               # True  — would this event do anything *right now*?
ed.can("SAVE")               # False — not handled in `clean`
ed.has_tag("idle")           # True
ed.tags                      # {'idle'}
ed.get_meta()                # {'editor.clean': {'hint': 'Nothing to save'}}
ed.context                   # {'saves': 0}
ed.is_running                # True

can() — disable buttons without duplicating logic

The machine already knows which events are legal. Ask it, instead of re-deriving the rule in your template:

save_button.disabled = not ed.can("SAVE")

Tags — style many states with one check

saving and dirty are different states but share the unsaved tag, so a spinner needs one condition rather than a growing or chain:

if ed.has_tag("busy"):
    show_spinner()

subscribe() — react to every settled transition

The listener receives the interpreter, so read whatever you need from it:

unsubscribe = ed.subscribe(
    lambda i: print(sorted(i.current_state_ids), i.context)
)
# … later
unsubscribe()

on() — listen for emitted events

emit publishes a domain event without coupling the machine to your transport:

ed.on("saved", lambda event: analytics.track(event.type))
ed.on("*", lambda event: audit_log.append(event))   # every emitted event
ed.send("EDIT"); ed.send("SAVE"); ed.send("OK")
ed.context     # {'saves': 1}   ← assign ran
ed.tags        # {'idle'}       ← back in `clean`

Plugins — the whole lifecycle, one line

from xstate_statemachine import LoggingInspector

ed.use(LoggingInspector())    # complete transition audit trail

Subclass PluginBase for metrics, tracing, or persistence-on-every-transition. Every hook is optional:

Hook Fires when
on_interpreter_start / on_interpreter_stop Lifecycle boundaries
on_event_received An event arrives, before any transition is chosen
on_transition A transition settles
on_guard_evaluated A guard returns — useful for "why didn't it fire?"
on_action_execute Before each action runs
on_action_error An action raised. Failures are contained, so without this hook they are invisible
on_service_start / on_service_done / on_service_error invoke lifecycle
from xstate_statemachine import PluginBase

class Metrics(PluginBase):
    def on_transition(self, interpreter, from_states, to_states, transition):
        statsd.increment(f"fsm.{transition.event}")

    def on_action_error(self, interpreter, action, error):
        sentry.capture_exception(error)   # otherwise silently contained

ed.use(Metrics())

⚖️ How It Compares

Python has good state machine libraries. Here's an honest read on when to pick which.

xstate-statemachine transitions python-statemachine
XState / Stately JSON ✅ runs unmodified ❌ ❌
Compound (nested) states ✅ ✅ ✅
Parallel regions ✅ ✅ ✅
History states ✅ ✅ ✅
invoke services + onDone/onError ✅ built-in ⚙️ DIY ⚙️ invoke (callables)
Delayed transitions (after) ✅ built-in ⚙️ Timeout extension (one OS thread per entry) ✅ delay=
Actor model / spawning ✅ ❌ ❌
Snapshot persistence ✅ ⚙️ DIY ⚙️ DIY
Sync and async runtimes ✅ two engines ✅ ✅
Diagram export ✅ no binaries ⚙️ needs graphviz ✅
CLI: generate, inspect, simulate, diagram, docs ✅ ❌ ❌
Virtual clock for tests ✅ SimulatedClock ❌ ❌
Bounded inbox / backpressure ✅ max_queue_size — —
Runtime dependencies 0 1 (six) 0

Pick transitions if you want the most battle-tested option and a simple FSM bolted onto an existing class. It's mature, widely deployed, and excellent at that job.

Pick python-statemachine if you want a beautiful, pythonic declarative API and don't need JS interop. It genuinely supports compound, parallel and history states too — this is a real alternative, not a strawman.

Pick this library when you want XState/Stately JSON to run in Python unchanged, or you want invoke, after, actors and snapshots as first-class primitives instead of patterns you assemble yourself.

Speed

Same machine shape, each library through its own idiomatic API, all measured in one session on 0.9.0 (Python 3.14, median of 7 runs, GC disabled, setup excluded). Events per second; bold is fastest in the row.

Scenario xstate-statemachine (sync) transitions 0.9.3 python-statemachine 3.2.1 sismic 1.6.11
Flat toggle 82,562 173,287 11,850 16,155
3-level nested 34,023 10,485 3,302 6,248
Parallel regions 56,239 7,468 4,751 5,930
Delayed transitions (timers/s) 11,609 72 4,664 6,805
Construction (machines/s) 12,442 10,475 1,813 362
1,000 instances (inst/s) 51,923 47,326 4,852 9,307

transitions is a transition table, not a statechart engine, and wins the flat scenario by ~2.1×. The moment states nest or run in parallel it has to emulate the SCXML algorithm and this library is 3.2–7.5× faster than everything else. It is also the fastest of the four to construct (1.19× transitions) and to fan out to 1,000 instances (1.10×), while still running the full build-time validator on every create_machine(). Full table, method and caveats: benchmarks/competitors/. The production numbers (throughput budget, after lateness under load) come from benchmarks/production_characteristics.py; run it with --json on your own hardware to gate CI on your figures.

When not to use this

For a three-state toggle with no I/O, a plain enum and an if is less machinery and easier to read. Statecharts start paying for themselves when you have concurrency, timeouts, cancellation, or more than ~5 states — and they pay enormously at 20.


📚 Cookbook

Real problems, small solutions.

Every recipe below is a fragment for readability. Here is one complete, runnable program first — a checkout that guards an empty cart, retries a declining card, and records the failure reason, in 40 lines:

🧾 A whole machine, end to end
from xstate_statemachine import (
    MachineLogic, SyncInterpreter, assign, create_machine,
)

ORDER = {
    "id": "order",
    "initial": "cart",
    "context": {"items": 0, "attempts": 0, "error": None},
    "states": {
        "cart": {
            "on": {
                "ADD": {"actions": assign(
                    {"items": lambda a: a["context"]["items"] + 1})},
                "CHECKOUT": {"target": "charging", "guard": "hasItems"},
            },
        },
        "charging": {
            "entry": assign({"attempts": lambda a: a["context"]["attempts"] + 1}),
            "invoke": {
                "src": "chargeCard",
                "onDone": "shipped",
                "onError": {
                    "target": "failed",
                    "actions": assign({"error": lambda a: str(a["event"].data)}),
                },
            },
        },
        "failed": {"on": {"RETRY": {"target": "charging", "guard": "canRetry"}}},
        "shipped": {"type": "final"},
    },
}


def charge_card(interpreter, context, event):
    """Fails the first time, succeeds on the retry."""
    if context["attempts"] < 2:
        raise RuntimeError("card declined")
    return {"receipt": "r-123"}


logic = MachineLogic(
    guards={
        "hasItems": lambda ctx, e: ctx["items"] > 0,
        "canRetry": lambda ctx, e: ctx["attempts"] < 3,
    },
    services={"charge_card": charge_card},
)

order = SyncInterpreter(create_machine(ORDER, logic=logic)).start()

order.send("CHECKOUT")                    # guard blocks — the cart is empty
print(sorted(order.current_state_ids))    # ['order.cart']

order.send("ADD")
order.send("CHECKOUT")                    # charges; the service raises
print(sorted(order.current_state_ids))    # ['order.failed']
print(order.context["error"])             # card declined

order.send("RETRY")                       # second attempt succeeds
print(sorted(order.current_state_ids))    # ['order.shipped']
print(order.context["attempts"])          # 2

Note what is absent: no try/except around the charge, no is_charging flag, no "did we already ship?" check. A declined card is a onError edge, "cart is empty" is a guard, and double-charging is impossible because shipped is final and charging has no CHECKOUT handler.

🔁 Retry with exponential backoff and a give-up limit

The pattern that turns into unreadable nested loops when hand-written:

config = {
    "id": "api",
    "initial": "idle",
    "context": {"attempt": 0},
    "states": {
        "idle": {"on": {"CALL": "loading"}},
        "loading": {
            "invoke": {
                "src": "callApi",
                "onDone": "success",
                "onError": [
                    {"target": "waiting", "guard": "canRetry"},
                    {"target": "failed"},               # out of retries
                ],
            }
        },
        "waiting": {
            "entry": assign({"attempt": lambda a: a["context"]["attempt"] + 1}),
            "after": {"BACKOFF": "loading"},
        },
        "success": {"type": "final"},
        "failed":  {"type": "final"},
    },
}

logic = MachineLogic(
    services={"call_api": call_api},
    guards={"canRetry": lambda ctx, e: ctx["attempt"] < 5},
    delays={"BACKOFF": lambda ctx, e: 2 ** ctx["attempt"] * 1000},
)

Attempt counting, backoff math, and the give-up condition are each in exactly one place.

🛒 Checkout that can't double-charge
"states": {
    "reviewing":  {"on": {"SUBMIT": "charging"}},
    "charging":   {                                  # ← no SUBMIT handler here
        "invoke": {"src": "chargeCard",
                   "onDone": "confirmed", "onError": "declined"},
    },
    "confirmed":  {"type": "final"},
    "declined":   {"on": {"SUBMIT": "charging"}},
}

The second click while charging does nothing. Not because you remembered to disable the button — because the state has no handler for it. The bug is unrepresentable.

🔌 Connection lifecycle with heartbeat
"states": {
    "disconnected": {"on": {"CONNECT": "connecting"}},
    "connecting": {
        "invoke": {"src": "openSocket", "onDone": "connected", "onError": "backoff"},
        "after": {"CONNECT_TIMEOUT": "backoff"},
    },
    "connected": {
        "on": {"PONG": "connected", "CLOSE": "disconnected"},   # self-transition resets timer
        "after": {"HEARTBEAT": "reconnecting"},
    },
    "backoff": {"after": {"RETRY_DELAY": "connecting"}},
    "reconnecting": {"on": {"CONNECT": "connecting"}},
}

A late onDone from a cancelled connection attempt is discarded — disconnected doesn't handle it. That's the zombie-callback class of bug, gone structurally.

🤖 LLM agent loop with supervised tool calls
"states": {
    "planning": {"invoke": {"src": "askModel",
                            "onDone": [{"target": "callingTool", "guard": "wantsTool"},
                                       {"target": "answering"}]}},
    "callingTool": {
        "entry": [{"type": "spawnChild",
                   "params": {"src": "toolRunner", "id": "tool", "systemId": "tool"}}],
        "on": {"TOOL_RESULT": "reflecting", "TOOL_FAILED": "recovering"},
        "after": {"TOOL_TIMEOUT": "recovering"},
    },
    "reflecting": {"always": [{"target": "planning", "guard": "needsMoreWork"},
                              {"target": "answering"}]},
    "recovering": {"always": [{"target": "planning", "guard": "canRetry"},
                              {"target": "givingUp"}]},
    "answering": {"type": "final"},
    "givingUp":  {"type": "final"},
}

The agent's control flow is data you can inspect, diagram and test — not a while loop with flags. Add LoggingInspector and you get a full audit trail of every decision.

🧪 Testing a machine without mocks

SyncInterpreter needs no event loop, so tests stay plain:

def test_declined_card_allows_retry():
    checkout = SyncInterpreter(create_machine(config, logic=test_logic)).start()

    checkout.send("SUBMIT")
    assert checkout.matches("checkout.charging")

    checkout.send("SUBMIT")                       # double click
    assert checkout.matches("checkout.charging")  # …ignored

Or skip the interpreter entirely with the pure API.


🐍 Prefer Pure Python? Three More Ways to Define a Machine

JSON is the interop format, not an obligation. If you're not sharing definitions with a frontend, define machines in Python instead.

Class-based — declarative and readable

from xstate_statemachine import State, StateMachine, SyncInterpreter, action

class Checkout(StateMachine):
    machine_id = "checkout"
    initial_context = {"attempts": 0}

    reviewing = State(initial=True)
    charging  = State()
    confirmed = State()

    submit = reviewing.to(charging, event="SUBMIT", actions=["recordAttempt"])
    ok     = charging.to(confirmed, event="PAID")

    @action
    def record_attempt(self, interpreter, ctx, evt, action_def):
        ctx["attempts"] += 1

c = SyncInterpreter(Checkout.create_machine()).start()
c.send("SUBMIT")
print(c.current_state_ids, c.context)   # {'checkout.charging'} {'attempts': 1}
c.send("SUBMIT")                        # double click → ignored
print(c.context)                        # {'attempts': 1}

Watch out — @action, @guard and @service convert snake_case method names to camelCase keys. The method record_attempt is referenced as "recordAttempt".

Compose multiple transitions for one event with |:

flip = off.to(on, event="TOGGLE") | on.to(off, event="TOGGLE")

Builder — fluent

from xstate_statemachine import MachineBuilder

machine = (MachineBuilder("toggle")
           .state("off", initial=True)
           .state("on")
           .transition("off", "TOGGLE", "on")
           .transition("on", "TOGGLE", "off")
           .build())

transition() takes (source, event, target), so states and transitions can be declared in any order — handy when you're generating a machine from data.

Functional — build_machine()

Plain objects and explicit wiring. The style to reach for when the machine is data you are assembling, not a shape you are declaring:

from xstate_statemachine import (
    State, SyncInterpreter, action, build_machine,
)

@action
def record_attempt(interpreter, ctx, evt, action_def):
    ctx["attempts"] += 1

reviewing = State("reviewing", initial=True,
                  on={"SUBMIT": {"target": "charging",
                                 "actions": ["recordAttempt"]}})
charging  = State("charging", on={"PAID": "confirmed"})
confirmed = State("confirmed", final=True, tags=["done"])

machine = build_machine(
    id="checkout",
    states=[reviewing, charging, confirmed],
    context={"attempts": 0},
    actions=[record_attempt],
)

c = SyncInterpreter(machine).start()
c.send("SUBMIT")
print(sorted(c.current_state_ids), c.context)   # ['checkout.charging'] {'attempts': 1}
c.send("PAID")
print(sorted(c.current_state_ids), sorted(c.tags))  # ['checkout.confirmed'] ['done']

Everything the JSON format supports

All three styles compile to the same MachineNode, so none of them is a reduced subset. Nesting, parallel regions, history, timers, tags and metadata are all expressible:

State("online", initial=True, states=[configuring, running, resume],
      on={"DISCONNECT": "offline"}, tags=["connected"])

State("resume", history="deep")           // remembers the last active child
State("failed", meta={"alert": True})     // arbitrary data for your UI
State("regions", parallel=True, states=[...])

Machine-level properties — a global escape transition, root entry/exit, or a parallel root — go on root=:

from xstate_statemachine import State, SyncInterpreter, build_machine

root = State("", on={"EMERGENCY": "halted"}, tags=["v2"])
machine = build_machine(
    id="press",
    states=[State("idle", initial=True), State("running"), State("halted")],
    root=root,
)

p = SyncInterpreter(machine).start()
p.send("EMERGENCY")                       # works from ANY state
print(sorted(p.current_state_ids))        # ['press.halted']

MachineBuilder.root(...) and a machine_root class attribute do the same for the other two styles.

Runnable examples for all three styles — building the same machine, with invoke, timers, guards, tags and meta — live in examples/sync/easy/pythonic_approach/.


🛠️ CLI Tool

xsm is the terminal companion to the library — a code generator, an inspector, a live simulator and a diagram/docs exporter in one zero-dependency command. Run it bare on a terminal for an interactive launcher with a menu, recent files and a generate wizard that previews before it writes; pipe it and every command degrades to clean plain text.

xsm                                                           # interactive launcher
xsm gt checkout.json -t pythonic-class --with-tests --with-types -o ./app
xsm inspect checkout.json                                     # tree, transitions, logic, policies
xsm simulate checkout.json                                    # live: pick events, +clock, undo
xsm sim checkout.json --events SUBMIT,+2001 --json            # scripted, for CI
xsm diagram checkout.json -f mermaid -o docs/
xsm docs machines/*.json -o docs/
Command Alias Does
generate-template gt Generate Python from a machine JSON — plus --with-tests, --with-types, --with-plugin companions
inspect ins State tree, transitions table, logic to implement, failure policies
simulate sim Run a machine on a simulated clock — interactively or from --events / --script
diagram dia Mermaid, PlantUML or ASCII to stdout or a file
docs A Markdown reference page per machine
validate val Build each file with the real library; list every finding
list-templates lt The 8 templates, grouped
info Version and feature summary
update Check PyPI and upgrade with the installer that installed you (pip / pipx / uv tool)
setup Windows: make xsm work where pip's xsm.exe launcher is blocked

Primary templates: class-json, function-json, pythonic-class, pythonic-builder, pythonic-functional. Companion templates: pytest (a test module recorded from the engine — one test per reachable step, green on day one), typed (TypedDict context, Literal events, typed stubs), plugin (a PluginBase wired for exactly the hooks the chart can fire).

The generator proves its output before writing it. For templates that build the machine in Python, xsm compiles the generated code, runs it, and compares the resulting machine against create_machine(your.json). If anything diverges it prints what and exits non-zero — nothing is written. Nesting, parallel regions, history, timers (numeric and named delays), composite guards, invoke, tags and meta all round-trip exactly.

Add --check in CI to catch generated code that has drifted from its source JSON:

xsm generate-template checkout.json --template pythonic-class -o ./app --check

--plain, --no-color (also NO_COLOR) and --no-anim control presentation; --json on validate, inspect, simulate, list-templates and info gives scripts the same facts. Full reference: CLI Tool.

Why generate instead of hand-write?

Because the machine already declares every logic name it needs. The generator reads them and emits a stub for each — so a typo in a guard name becomes a missing-function error at generation time rather than an ImplementationMissingError in production.


🏭 Running It in Production

Everything above is the happy path. Here is what matters once real traffic arrives.

Throughput, timers and threads — read this before sizing

All async interpreters in a process share one event loop on one thread: throughput is a per-process budget (~30k trivial ev/s on a laptop), divided among your machines. after timers now fire through a priority lane the run loop checks ahead of its inbox — ~45 ms late at 500 busy machines, down from ~180 ms before 0.8.0 — and a SyncInterpreter timer only fires when someone calls send() or tick(), on the caller's thread. In fact, neither engine spawns an OS thread per timer anymore; the only thread work either one does is running a non-blocking spawn_* child. Pass Interpreter(clock=SimulatedClock()) in tests to fire an after timer without sleeping — see Testing below. The measured tables and a sizing rule are in Production Characteristics — the one page to read before deploying.

Failure semantics — know what is contained

Each is a per-machine policy. The default preserves the historical behaviour; production machines should opt in explicitly.

What fails Default Opt-in policy (machine config key) How to observe it
An action raises (entry, exit, or transition) Contained; the transition still commits actionErrorPolicy: "rollback" restores configuration and context · "fail" also stops the machine (status == "stopped", configuration cleared) with TransitionFailedError on .error on_action_error, on_transition_failed, interpreter.last_transition_ok
A guard raises Treated as False guardErrorPolicy: "true" · "raise" (takes the next candidate transition first, then surfaces the exception) on_guard_error (distinct from a guard that returned False)
An invoked service raises Routed to onError — a normal transition, not a crash — onError target, on_service_error
An unknown event arrives Ignored (XState semantics) onUnhandled: "defer" replays it after the next state change · "error" stops with UnhandledEventError on_unhandled_event fires under every policy
A transition target does not resolve Rejected at create_machine() strict_targets=False downgrades to a DeprecationWarning (removed in 1.0) InvalidConfigError lists every bad target at once
A config key is misspelled — at the root or inside any state, transition or invoke Logged at WARNING with a "did you mean" hint and the path (m.a: 'entyr' (did you mean 'entry'?)) create_machine(..., strict_config=True) or config "strictConfig": true refuses with InvalidConfigError The build log; x-… keys and meta/description/tags are always accepted
A self-generated chain runs away — a zero-delay raise/self-send cycle or an always loop Cut at maxIterations (default 1000); the machine stays running Tune maxIterations; a delayed self-send is a timer and is never counted Sticky: interpreter.chain_trips, interpreter.last_chain_error (cleared only by clear_chain_error(), survives a snapshot), on_chain_budget_exceeded once per trip; on_invocation_stranded if the cut parked a state whose service will never complete
An action awaits its own send(wait=True) — — ReentrantWaitError at the call site instead of a silent deadlock (both engines); a plain-def action that drops the result gets a RuntimeWarning
The inbox is full Unbounded (no limit) max_queue_size=, overflow_policy=OverflowPolicy.RAISE (default once bounded) · BLOCK · DROP_NEWEST RAISE raises QueueOverflowError; DROP_NEWEST calls on_event_dropped; interpreter.queue_depth
An undeclared event is sent under strict N/A — strict is opt-in Machine config strict: true or Interpreter(strict=True) UnknownEventError at the send() call site, before queueing; event_schemas= on create_machine() raises InvalidEventPayloadError for a bad payload regardless of strict. Both checks also apply to events restored from a snapshot — a refusal fires on_invalid_event (pass from_snapshot(..., plugins=[...]) to see it) and lands on last_error

Containment by default is deliberate: a long-lived machine should not die because one side effect had a bad day. The cost is that failures are invisible unless you look, so wire up the hooks early — every one of them fires whatever policy you choose:

from xstate_statemachine import PluginBase

class ErrorReporter(PluginBase):
    def on_action_error(self, interpreter, action, error):
        sentry.capture_exception(error)

interp.use(ErrorReporter())

Note — actionErrorPolicy defaults to "continue" today (with a one-shot DeprecationWarning); it flips to "rollback" in 1.0. Pin it explicitly if you need today's behaviour to survive the upgrade.

Asking the machine a question

send() normally fires and forgets. Pass wait=True to get a Receipt once that exact event's macrostep has run — no polling, no wait_for():

receipt = await interp.send("SUBMIT", wait=True)
# Receipt(state_ids=frozenset({'checkout.paying'}), changed=True, error=None,
#         deferred=False, denied=False)

await interp.send_priority("CANCEL")   # ahead of the inbox, exempt from its bound

priority=True on send() does the same as send_priority(). A Receipt has five fields — read them by attribute; deferred says the event was parked under onUnhandled: "defer", denied that a handler existed but every guard said no.

Two rules for wait=True inside an action: don't await your own receipt on the action's own task (the run loop can't advance until the action returns, so it raises ReentrantWaitError rather than deadlocking), and don't drop it from a plain def action (it warns). Hand it out — asyncio.ensure_future(i.send("GO", wait=True)) — or send without wait; a helper task the action spawns may await freely.

Waiting for a machine to settle

Do not poll by hand or sleep() and hope:

from xstate_statemachine import wait_for, wait_for_sync, to_promise

# async
await wait_for(interp, lambda i: i.matches("job.done"), timeout=30)
result = await to_promise(interp)          # resolves when the machine reaches a final state

# sync
wait_for_sync(interp, lambda i: i.matches("job.done"), timeout=30)

Choosing an interpreter

Use When
Interpreter asyncio apps — FastAPI, aiohttp, bots, anything already async
SyncInterpreter Django views, Celery tasks, CLI tools, scripts, tests

Same machine JSON, same semantics, same guarantees. Timers, services and actors all work on both engines; neither spawns an OS thread per timer — the sync engine delivers a due after timer on the caller's own thread inside send() or tick(), and the async engine delivers it through the priority lane described above. A spawn_* (non-blocking) child is the one thing either engine runs off-thread.

Long-running machines

  • Persist on transition, not on a timer — get_persisted_snapshot() in a subscribe() callback gives you crash-safe resume points.
  • after timers are not re-armed by default. A snapshot records that a timer was pending, not how far along it was; after a static restore has_dormant_timers is True. Pass from_snapshot(..., restart_timers=True) to re-arm each from zero, or model the deadline as data in context. A timer that had already fired is in the snapshot and replays. A delayed self-send (raise/send with delay) is persisted with its remaining time and resumes where it would have been.
  • Invokes do not restart on restore either — from_snapshot() is a static rebuild that starts nothing by default. Call pending_invocations() on the restored interpreter to see every PendingInvocation(state_id, invoke_id, src) with no live service (has_dormant_invocations is the boolean), and from_snapshot(..., restart_services=True) to re-invoke each of them from scratch.
  • Snapshots carry an envelope (version — layout 3 — machine_id, machine_hash) so a restore against a machine that no longer matches the one that produced the snapshot fails loud with SnapshotDriftError instead of resuming into undefined behaviour. Pass verify_machine_hash=False after a deliberate migration. The hash is a drift check, not an authentication tag: if a blob crosses a trust boundary, sign it outside and pin from_snapshot(..., minimum_version=3, expected_machine_hash=...) so the payload cannot pick its own level of checking.
  • What else round-trips: pending events with their lane (priority events restore ahead of the inbox on both engines), deferred events, armed delayed sends, history, child actors, the error, and the chain-trip latch (chain_trips / last_chain_error) — a restart is not an acknowledgement. strict and event_schemas are applied to every restored event; a refusal is reported, not silently admitted.
  • Always stop() — it cancels timers and stops spawned actors. In a web app, tie it to request teardown; in a worker, to the task's finally.

Testing

The pure API is the simplest way to test machine logic — no event loop, no mocks, no sleeping:

from xstate_statemachine import get_initial_snapshot, get_next_snapshot

snap = get_initial_snapshot(machine)
snap = get_next_snapshot(machine, snap, "SUBMIT")
assert snap.matches("checkout.paying")

Use a real interpreter for integration tests, where you want the actions to actually run. For an after timer, don't sleep — inject a SimulatedClock and jump virtual time:

from xstate_statemachine import SyncInterpreter, SimulatedClock

clock = SimulatedClock()
interp = SyncInterpreter(machine, clock=clock).start()
clock.increment(30_000)             # fires a 30 s `after` with no real delay
assert interp.matches("job.timedout")

📘 API Reference

Core — building and running
Name Purpose
create_machine(config, *, context_type=None, logic=None, logic_modules=None, logic_providers=None, strict_targets=True, event_schemas=None, strict_config=None) Build a machine from a dict/JSON config. strict_targets=False downgrades unresolvable transition targets to a DeprecationWarning (removed in 1.0). event_schemas={'FILL': Fill} adds opt-in payload validation — a callable that raises to reject, a dataclass, or anything with a model_validate-style constructor — raising InvalidEventPayloadError at the send() call site regardless of strict. strict_config=True (or config "strictConfig": true) refuses an unknown key anywhere in the config with InvalidConfigError; the default logs a WARNING with a "did you mean" hint and the path
MachineLogic(actions=, guards=, services=, delays=, *, strict=False) Bind names in the config to Python callables. snake_case and camelCase names match each other; two different callables whose names differ only by case/separators are rejected. strict=True refuses undecorated registrations
Interpreter(machine, input=None, clock=None, max_queue_size=None, overflow_policy=OverflowPolicy.RAISE, strict=None, service_executor=None, service_pool_size=4) Async engine — await .start(children_timeout=2.0), .send(), .stop(). Plain-def services run on a private thread pool of service_pool_size workers (or your service_executor) so a blocking service cannot stall the loop
SyncInterpreter(machine, input=None, clock=None, strict=None, max_queue_size=None, overflow_policy=None) Sync engine — no event loop anywhere. No inbox bound by design (send() runs each event to completion before returning); the two bound kwargs exist for parity and a non-None bound raises ValueError
LogicLoader Auto-discover logic by name from modules
MachineNode The parsed machine; has .to_mermaid() / .to_plantuml()

strict (constructor arg, wins over the machine's strict config key) makes send() raise UnknownEventError synchronously — before the event is queued — for any event type the machine has never declared, with a difflib suggestion ('Did you mean FILL?'). max_queue_size bounds the inbox; once set, overflow_policy decides what happens when it's full — see OverflowPolicy below.

You can also subclass MachineLogic and define actions, guards and services as methods — they're registered automatically by arity: (ctx, event) is a guard, (interpreter, ctx, event) a service, (interpreter, ctx, event, action) an action.

Interpreter surface
Member Purpose
.start() / .stop(drain=False, timeout=None) Lifecycle (await both on Interpreter). stop(drain=True) processes the inbox to empty first (async also takes timeout=)
.send(event, *, wait=False, priority=False, **payload) Send an event; kwargs become event.payload. wait=True returns (async: awaits) a Receipt; priority=True delivers ahead of the inbox, exempt from its bound
.send_priority(event, **payload) Async only — shorthand for send(event, priority=True, wait=True, **payload)
.send_threadsafe(event, internal=None, **payload) Async only — send from a foreign OS thread; returns a concurrent.futures.Future. send() from a foreign thread — including via asyncio.run_coroutine_threadsafe — raises WrongThreadError instead. internal=True charges the send to maxIterations as a self-send (an action that hands its own re-trigger to a plain thread)
.tick() Sync only — deliver any timer that has come due since the last call, outside of send()
.current_state_ids / .active_state_ids Set of active leaf state ids
.value Active configuration in XState's hierarchical form — a leaf key, {parent: child}, or one key per parallel region; {} before start()
.context The live context dict
.status / .is_running "running" / "stopped", and a liveness check
.matches(id_or_value) Is this state active? Accepts a string path or a partial .value-shaped dict
.can(event) Would this event cause anything?
.has_tag(tag) / .get_meta() Tags and merged meta of active states
.subscribe(fn) Observe every transition
.use(plugin) / .plugins Register plugins
.system Actor registry — .get(system_id), .get_all()
.queue_depth Current inbox depth (0 for an unbounded queue with nothing pending)
.pending_events Accepted-but-unprocessed events, FIFO
.deferred_count Events buffered by onUnhandled: "defer", awaiting replay
.last_transition_ok / .last_error Per-step: False / the exception when the most recent step failed (a raising action under actionErrorPolicy, an unresolvable target, a chain cut). Reset by the next clean event — not a latch
.chain_trips / .last_chain_error / .clear_chain_error() Sticky record that maxIterations cut work: a monotonic count and the latched RunawayChainError. Survive later events and a snapshot; only clear_chain_error() clears the latch
.last_plugin_error (plugin_class, hook, error) for the most recent plugin hook that raised; plugin failures never stop the machine
.has_dormant_invocations / .has_dormant_timers True after a static restore left an active invoke with no live service / an after timer not armed
.pending_invocations() List[PendingInvocation] — every active state with no live service/child actor (e.g. after a static restore)
.drain_pending() Remove and return every pending event without processing it — both lanes on the async engine, priority first (fired timers, completions, send_priority()); the receipt on a drained wait=True event is failed
.dropped_receipts Async only — count of send(wait=True) receipts a def action dropped unawaited; the gateable form of the RuntimeWarning (see also on_receipt_dropped)
.restored_from_snapshot True on an instance built by from_snapshot(); on_interpreter_start fires on resume too, so read this to tell it from bring-up
.wait_done() Async only — a future that resolves the instant the machine reaches done/error
.get_snapshot() / .get_persisted_snapshot() Serialize (JSON string / dict) — layout v3: version, machine_id, machine_hash, taken_at, value, configuration, context, pending_events (with lane and engine provenance), deferred, scheduled_sends, history, actors, error, chain_trips, last_chain_error. Raises SnapshotMidStepError mid-transition and SnapshotSerializationError for non-JSON data
.from_snapshot(snap, machine, *, verify_machine_hash=True, restart_services=False, restart_timers=None, clock=None, minimum_version=0, expected_machine_hash=None, plugins=None) Restore (classmethod). Older layouts upcast transparently; SnapshotVersionError for a newer one or one below minimum_version; SnapshotDriftError on an id/hash mismatch; SnapshotCorruptError for a malformed blob. restart_services / restart_timers re-drive dormant work; plugins= registers plugins before restored events are admitted so a strict/schema refusal reaches on_invalid_event
Config keys — every key the parser reads

The complete per-level key sets. Anything else is reported as unknown (WARNING by default, InvalidConfigError under strict_config=True); meta / description / tags and any x-… key are accepted at every level.

Level Keys
Root (everything a state accepts, plus) context · version · strict · strictTargets · strictConfig · maxIterations · spawnBlockingTimeout · actionErrorPolicy (continue | rollback | fail) · guardErrorPolicy (false | true | raise) · onUnhandled (ignore | defer | error)
State id · type (atomic | compound | parallel | final | history) · initial · states · entry · exit · on · always · after · invoke · onDone · history (shallow | deep) · target (a history state's default) · output (final state's done-data) · meta · description · tags
Transition target · actions · guard (alias cond) · reenter (alias internal, inverted) · meta · description · tags
Invoke src (a service name) · id · input · systemId · onDone · onError · meta · description · tags

Constants: DEFAULT_CHILDREN_TIMEOUT (2.0 s, Interpreter.start(children_timeout=)), DEFAULT_SERVICE_POOL_SIZE (4, Interpreter(service_pool_size=)), ENGINE_EVENT_SHAPES / SYSTEM_EVENT_PREFIXES (the name shapes the engine mints — for documentation and build-time checks only; provenance is decided by is_system_event, not by name). BaseInterpreter is the shared base of both engines, for type annotations that accept either. Full semantics of every key: JSON Configuration.

Action creators

assign · log · raise_ · send_to · send_parent · choose · pure · enqueue_actions · ActionEnqueuer · spawn_child · stop_child · cancel · emit · escalate · forward_to

Clock
Name Purpose
Clock Protocol every clock implements: .now(), .set_timeout(fn, delay_sec, owner=), .clear_timeout(handle), .pending
RealClock() Wall-clock time (default). Delivers a fired after timer through a priority lane the async run loop checks ahead of the inbox, so a due timer can't be starved behind a burst of external events
SimulatedClock() Virtual time — nothing advances until you do. .now(), await .set(ms), await .increment(ms), .pump() (fire everything due, returns the count fired), .pending (count of armed timers)

Pass Interpreter(clock=) / SyncInterpreter(clock=); spawned and invoked children inherit the parent's clock (and its strict setting). SyncInterpreter never spawns an OS thread for an after timer or delayed send — a due deadline is delivered on the caller's thread at the top of send(), in the macrostep loop, or by .tick().

Pure API & helpers
Name Purpose
initial_transition(machine) → (PureSnapshot, actions) for the initial state
pure_transition(machine, snap, event) → (PureSnapshot, actions) — no side effects
get_next_snapshot(machine, snap, event) → next PureSnapshot only
get_initial_snapshot(machine) → initial PureSnapshot
PureSnapshot .state_ids .context .status .output .matches()
wait_for(interp, pred, timeout=) Await a predicate (async)
wait_for_sync(interp, pred, timeout=) Block on a predicate (sync)
to_promise(interp) Await a machine reaching a final state
Plugins, data classes & exceptions

Plugins — PluginBase hooks (22; LoggingInspector(redact_keys=, log_context=) implements the transition/action/guard/service/lifecycle ones): on_interpreter_start · on_interpreter_stop · on_transition · on_event_received · on_action_execute · on_action_error · on_guard_evaluated · on_guard_error · on_service_start · on_service_done · on_service_error · on_transition_failed · on_unhandled_event · on_event_dropped · on_error · on_done · on_resolve_error · on_plugin_error · on_invalid_event · on_snapshot_error · on_invocation_stranded · on_chain_budget_exceeded · on_receipt_dropped

Hooks are synchronous callbacks; an async def hook is never awaited and is reported through on_plugin_error / last_plugin_error.

Data classes:

Name Purpose
Receipt(state_ids, changed, error, deferred, denied) Returned by send(wait=True) once the macrostep for that event has run. Five fields — read by attribute; a positional destructure written for fewer raises ValueError
Event / DoneEvent / ErrorEvent / AfterEvent The event types an action or hook receives. Service and child failures arrive as ErrorEvent(type, error, src). is_system_event(ev) is True only for events the engine minted — a hand-built DoneEvent("done.invoke.x", ...) is user traffic and is refused under strict. ev._replace(...) on an engine event is a one-way demotion to user traffic; re_mint(ev, **fields) is the sanctioned way to patch a field and keep provenance (it accepts only an engine-minted input)
OverflowPolicy RAISE (default once max_queue_size is set) · BLOCK · DROP_NEWEST
PendingInvocation(state_id, invoke_id, src) An active state with no live service/child actor
ActionDefinition(config) The 4th positional arg every action callable receives — .type (action name) and .params (static params from the config, if any)

Exceptions: XStateMachineError (base) · InvalidConfigError (and its subclass RootTargetError) · StateNotFoundError · ImplementationMissingError · ActorSpawningError · NotSupportedError · UnhandledEventError · TransitionFailedError · WrongThreadError · QueueOverflowError · InterpreterStoppedError · UnknownEventError · InvalidEventError (also a TypeError) · InvalidEventPayloadError · RunawayChainError · ReentrantWaitError · RestoredError · RestoredChainError (both a RestoredError and a RunawayChainError) · SnapshotDriftError · SnapshotVersionError · SnapshotCorruptError · SnapshotMidStepError · SnapshotSerializationError

Version: from xstate_statemachine import __version__ gives the installed version string — the same value xsm -v / xsm info report.


🚨 Troubleshooting

The errors you are most likely to meet, and what each actually means.

ImplementationMissingError — "no implementation was found"

Your machine names an action, guard or service that nothing provides. This is a feature: a typo in a guard name becomes an error at load time instead of a transition that mysteriously never fires.

create_machine({"id": "a", "initial": "s",
                "states": {"s": {"entry": "logStart"}}})
# ImplementationMissingError: Action 'logStart' is defined in the machine
# but no implementation was found …

Fix — supply it, or opt out explicitly:

create_machine(config, logic=MachineLogic(actions={"logStart": my_fn}))
create_machine(config, logic=MachineLogic())   # accept the stubs; nothing runs

MachineLogic() with no arguments is the right choice for tests, diagram export, and the pure API, where actions never execute.

StateNotFoundError — a transition points nowhere
{"s": {"on": {"GO": "ghost"}}}     # 'ghost' is not a sibling of 's'

Targets are scope-relative, resolved from the source state outward. Common causes:

Symptom Cause
Target is a child of another state Use "parent.child" or "#machineId.parent.child"
Target is in a different branch Use an absolute "#machineId.path" reference
.child did not resolve A leading dot resolves from the source's parent, not the source

Run xsm validate machine.json to catch these before runtime.

InvalidConfigError — the machine itself is malformed

Missing states, a bad initial, an invoke.src that is not a service name (an inline machine dict, say), or — under strict_config=True — a misspelled key anywhere in the config. Without strict_config a misspelled key is a WARNING naming the path and the likely intent, and the machine builds with that key ignored, which is why the warning exists: "entyr" means an entry action that never runs, "onn" a transition that does not exist.

create_machine({"id": "c"})        # InvalidConfigError: 'states' key is missing
create_machine({"id": "c", "initial": "a", "states": {"a": {"entyr": ["x"]}}},
               strict_config=True)
# InvalidConfigError: Machine 'c' has unknown config key(s) -- c.a: 'entyr'
#   (did you mean 'entry'?) ...

A corrupt snapshot string is SnapshotCorruptError, not this.

RunawayChainError — "exceeded N chained self-generated events"

An action raises (or sends to its own machine) the event that triggers it again with no delay, or two always transitions target each other. The machine cuts the tail at maxIterations (default 1000), discards it, and keeps running — so the only signals are the ones you read:

interp.chain_trips          # monotonic, survives a snapshot
interp.last_chain_error     # the RunawayChainError, latched until clear_chain_error()
receipt.error               # on the send(wait=True) that tripped it

last_error also carries it, but only until the next clean event. A delayed self-send (raise with delay, or after) is a timer and never counts: a heartbeat of any period runs indefinitely.

ReentrantWaitError — "awaited send(..., wait=True) on its own interpreter"

An action did await i.send("GO", wait=True). The receipt resolves when the run loop processes GO, and the loop cannot advance until the action returns — a deadlock, so it raises immediately instead. Send without wait (the event runs right after the current step), or hand the receipt to another task:

async def act(i, ctx, event, action):
    i.send("GO")                                         # fine
    fut = asyncio.ensure_future(i.send("GO", wait=True))  # fine: awaited elsewhere
    await i.send("GO", wait=True)                        # ReentrantWaitError

A helper task the action spawns may await the machine freely — the rule is about the action's own task. A plain def action that calls send(wait=True) and drops the result gets a RuntimeWarning instead: it received an awaitable it cannot await.

My action ran but nothing happened

Action failures are contained — the transition completes and the machine keeps running. That is deliberate for long-lived machines, but it means a raising action is invisible unless you look:

class ErrorReporter(PluginBase):
    def on_action_error(self, interpreter, action, error):
        raise error          # or log it, or ship it to Sentry

interp.use(ErrorReporter())
My event did nothing

An event that the current state does not handle is ignored, by design — send("BANANA") is a no-op, never an exception. Three ways to find out why:

interp.can("SUBMIT")          # False → not handled here at all
interp.current_state_ids      # are you in the state you think you are?
interp.use(LoggingInspector())  # shows guards evaluating and rejecting

If can() is True but nothing moves, a guard is returning False. The on_guard_evaluated plugin hook tells you which one.

My after timer never fired after restoring a snapshot

Correct by default. A snapshot records that a timer was pending, not how far along it was, so a static restore leaves it dormant (interp.has_dormant_timers is True). Pass from_snapshot(..., restart_timers=True) to re-arm every dormant timer from zero on start(). A timer that had already fired, and a delayed self-raise, are persisted and resume.

If the exact remaining time must survive a restart, model it as data:

"entry": assign({"deadline": lambda a: time.time() + 30}),

…then compare against wall-clock on resume, rather than relying on after.


❓ FAQ

Do I have to use JSON?

No. JSON is what makes frontend interop possible, but the class-based, builder and functional APIs are all first-class. Use JSON when you're sharing a definition; use Python when you're not.

Async or sync — which interpreter?

SyncInterpreter if your code isn't already async: Django/WSGI views, Celery tasks, CLI tools, scripts, tests. It is genuinely synchronous — there is no hidden event loop, and it raises NotSupportedError rather than silently starting one if you hand it async logic.

Interpreter for asyncio applications, and whenever you need concurrent services or timers that don't block.

Both share one correctness core, so a machine behaves identically on either.

Can I really run an unmodified Stately.ai export?

Structurally, yes — 103 of the 104 real-world exports in the test suite parse unchanged, and both v4 cond and v5 guard spellings are accepted.

What doesn't transfer is JS/TS action implementations, because those are code rather than data. You supply Python equivalents through MachineLogic. That separation is the point: the shape of the flow is shared, the side effects are native to each platform.

What happens if an action raises?

It's logged and contained. The transition completes and the interpreter keeps running, so one bad side effect can't kill a long-lived machine. To react to a failure, catch it in the action and record it on context, then guard a transition on that flag.

Invoked services are different — their failures are routed back into the machine as onError, which is the idiomatic way to model expected errors.

Is this production ready?

3,735 tests, 93% coverage, CI runs the full matrix — Python 3.9–3.14 × Linux, macOS and Windows. The engine implements the SCXML transition-selection algorithm and there's a dedicated test suite pinning that behaviour, plus one pinning XState v5 parity.

Zero runtime dependencies means nothing to audit, no version conflicts, and it works in slim containers and locked-down environments.

Does it support SCXML files?

No. The engine implements the SCXML algorithm — which is why nested and parallel behaviour matches XState rather than approximating it — but it does not read or write .scxml documents.


🗺️ Diagrams

Every machine can draw itself, with no graphviz install:

print(machine.to_mermaid())      # paste into GitHub, Notion, Obsidian…
print(machine.to_plantuml())

📖 Full Documentation

basiltt.github.io/xstate-statemachine

Guides · API reference · What's new and the upgrade notes · Changelog · More examples


Contributing

Issues and PRs welcome — see CONTRIBUTING.md. Every PR runs the full matrix: lint, the full test suite, a coverage gate, and a packaging check.


MIT Licensed · Built with precision. Tested with rigour.


If this saved you from a 3am impossible-state bug, consider starring the repo ⭐

Release files for xstate-statemachine 0.10.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for xstate-statemachine 0.10.1
File Size Uploaded
xstate_statemachine-0.10.1.tar.gz 1.4 MB Details

Built distribution (wheel)

Table of built distributions (wheels) for xstate-statemachine 0.10.1
File Interpreter ABI Platform
xstate_statemachine-0.10.1-py3-none-any.whl Python 3 none any Details

Total release size: 1.9 MB

Release files / xstate_statemachine-0.10.1.tar.gz

Download URL xstate_statemachine-0.10.1.tar.gz
Size 1.4 MB
Tags Source
SHA-256 checksum
How to use checksums
d93e8bff059487b413951e4a3c18a7683555d5b7f8481e9ac1a423a34c6fcbcc
BLAKE2b-256 checksum
How to use checksums
f4bffa0edb604f275ffdeb586b1daf4d561a04311512bcfe7d11c929aed838eb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.

Transparency log

Release files / xstate_statemachine-0.10.1-py3-none-any.whl

Download URL xstate_statemachine-0.10.1-py3-none-any.whl
Size 473.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
c3a7edf698de9c58d2f015e8acfbba11cdc26fb601e0094a4bbfa16eac8d7995
BLAKE2b-256 checksum
How to use checksums
f61ec054648c23e84480914d7f0940e1fdd18dc86b14e05f4b685fd9831f3633
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.

Transparency log

Release history Release notifications | RSS feed

0.10.5

2 release files

0.10.4

2 release files

0.10.3

2 release files

0.10.2

2 release files

This release

0.10.1 This release

2 release files

0.10.0

2 release files

0.9.1

2 release files

0.9.0

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.3

2 release files

0.4.2

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.3

2 release files

0.2.2

2 release files

0.2.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page