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 · Compare · API · Docs


🚀 Install

pip install xstate-statemachine

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

xsm info          # verify the install
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={"chargeCard": charge_card},   # you supply the Python side
    guards={"hasStock": 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 guardsand, 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, record it on context and guard on it.


🔌 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={"fetchUser": 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())
  • SuccessonDone, with the return value on event.data
  • FailureonError, 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.


🧪 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 create_machine, initial_transition, pure_transition

machine = create_machine(config)

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])  # the actions that 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

Every interpreter answers questions about itself:

interp.matches("checkout.paying")     # bool — supports nested paths
interp.can("SUBMIT")                  # would this event do anything right now?
interp.has_tag("busy")                # state tags, great for UI binding
interp.get_meta()                     # merged `meta` from active states
interp.subscribe(lambda snap: ...)    # observe every transition

Plugins hook the whole lifecycle — one line gives you a complete transition audit trail:

from xstate_statemachine import LoggingInspector

interp.use(LoggingInspector())

Subclass PluginBase for metrics, tracing, or persistence-on-every-transition.


⚖️ 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 ⚙️ DIY
Delayed transitions (after) ✅ built-in ⚙️ DIY ⚙️ DIY
Actor model / spawning
Snapshot persistence ⚙️ DIY ⚙️ DIY
Sync and async runtimes ✅ two engines
Diagram export ✅ no binaries ⚙️ needs graphviz
CLI code generator
Runtime dependencies 0 0 (core) few

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.

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.

🔁 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={"callApi": 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()

For programmatic construction where a dict is the natural shape.


🛠️ CLI Code Generator

Point xsm at an XState JSON file and get runnable, typed Python scaffolding — every action, guard and service stubbed with the right signature.

xsm generate-template checkout.json --template pythonic-class -o ./app
Command Alias Does
generate-template gt Generate Python from a machine JSON
list-templates lt Show the 5 available templates
validate val Check a JSON machine for structural errors
info Version and feature summary

Templates: class-json, function-json, pythonic-class, pythonic-builder, pythonic-functional.

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.


📘 API Reference

Core — building and running
Name Purpose
create_machine(config, logic=..., logic_modules=[...]) Build a machine from a dict/JSON config
MachineLogic(actions=, guards=, services=, delays=) Bind names in the config to Python callables
Interpreter(machine) Async engine — await .start(), .send(), .stop()
SyncInterpreter(machine) Sync engine — no event loop anywhere
LogicLoader Auto-discover logic by name from modules
MachineNode The parsed machine; has .to_mermaid() / .to_plantuml()

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() Lifecycle (await both on Interpreter)
.send(event, **payload) Send an event; kwargs become event.payload
.current_state_ids / .active_state_ids Set of active leaf state ids
.context The live context dict
.status / .is_running "running" / "stopped", and a liveness check
.matches(id) Is this state active? Supports nested paths
.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()
.get_snapshot() / .get_persisted_snapshot() Serialize (JSON string / dict)
.from_snapshot(snap, machine) Restore (classmethod)
Action creators

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

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 & exceptions

Plugins: PluginBase, LoggingInspector

Exceptions: XStateMachineError (base) · InvalidConfigError · StateNotFoundError · ImplementationMissingError · ActorSpawningError · NotSupportedError


❓ 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?

2,682 tests, 88% coverage, CI across Python 3.9–3.14 on 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 · Migration notes · More examples


Contributing

Issues and PRs welcome — see CONTRIBUTING.md. Every PR runs the full matrix: lint, 2,682 tests, 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.6.0

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.6.0
File Size Uploaded
xstate_statemachine-0.6.0.tar.gz 673.7 kB Details

Built distribution (wheel)

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

Total release size: 878.6 kB

Release files / xstate_statemachine-0.6.0.tar.gz

Download URL xstate_statemachine-0.6.0.tar.gz
Size 673.7 kB
Tags Source
SHA-256 checksum
How to use checksums
4b5e80ea398a5302311de90238fb37c9ec8a83e1df79d77a6b4ca0928083cb9e
BLAKE2b-256 checksum
How to use checksums
495f1cb927c83fbdaaf8706960e5088ca07a301422c258275f5deb0d88581492
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.6

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

Download URL xstate_statemachine-0.6.0-py3-none-any.whl
Size 204.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e31080ab15446fcb2adc361df300b2a49ac008ff686d6c53454088dcb11dcac0
BLAKE2b-256 checksum
How to use checksums
5105f7c757aafcd63ca0259037a6d78aaeaf61ec33a8bac2a63d2317b66254f0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.6

Release history Release notifications | RSS feed

0.8.0

2 release files

0.7.0

2 release files

This release

0.6.0 This release

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