Getting Started
Install XState-StateMachine, verify your setup, build your first machine, and understand the full feature set.
π¦ Installation
Install from PyPI with pip:
pip install xstate-statemachine
Or with uv for faster installs:
uv pip install xstate-statemachine
Or with Poetry:
poetry add xstate-statemachine
Verify Your Installation
xsm --version
# Output: xsm 0.8.0
You can also verify the CLI tool is available:
xsm --help
Expected output:
usage: xsm [-h] [-v]
{generate-template,gt,list-templates,lt,validate,val,info} ...
XState-StateMachine CLI β Generate Python code from XState JSON.
positional arguments:
{generate-template,gt,list-templates,lt,validate,val,info}
Available commands
generate-template (gt)
Generate Python code from an XState JSON file.
list-templates (lt)
List all available code generation templates.
validate (val) Validate an XState JSON config file.
info Show library version, Python version, and feature
summary.
options:
-h, --help show this help message and exit
-v, --version Show program's version number and exit.
π Requirements
| Requirement | Details |
|---|---|
| Python | 3.9 β 3.14 (CI runs every version on Linux, macOS and Windows) |
| Dependencies | None β zero external dependencies beyond the standard library |
| OS | Windows, macOS, Linux |
Tip: The library uses only the Python standard library, so it works anywhere Python runs β containers, serverless, embedded systems, CI pipelines.
β‘ Your First State Machine (60 seconds)
Letβs build a simple toggle switch. It has two states (off and on) and toggles between them:
stateDiagram-v2
direction LR
[*] --> off
off --> on : TOGGLE
on --> off : TOGGLE
Using JSON (XState-compatible):
from xstate_statemachine import create_machine, SyncInterpreter
config = {
"id": "toggle",
"initial": "off",
"states": {
"off": {"on": {"TOGGLE": "on"}},
"on": {"on": {"TOGGLE": "off"}}
}
}
machine = create_machine(config)
interp = SyncInterpreter(machine).start()
print(interp.active_state_ids)
# {'toggle.off'}
interp.send("TOGGLE")
print(interp.active_state_ids)
# {'toggle.on'}
interp.send("TOGGLE")
print(interp.active_state_ids)
# {'toggle.off'}
interp.stop()
Using Pure Python (Pythonic API):
from xstate_statemachine import State, build_machine, SyncInterpreter
off = State("off", initial=True, on={"TOGGLE": "on"})
on = State("on", on={"TOGGLE": "off"})
machine = build_machine(id="toggle", states=[off, on])
interp = SyncInterpreter(machine).start()
interp.send("TOGGLE")
print(interp.active_state_ids)
# {'toggle.on'}
interp.stop()
Using Class-Based Style:
from xstate_statemachine import State, StateMachine, SyncInterpreter
class ToggleMachine(StateMachine):
machine_id = "toggle"
off = State("off", initial=True, on={"TOGGLE": "on"})
on = State("on", on={"TOGGLE": "off"})
machine = ToggleMachine.create_machine()
interp = SyncInterpreter(machine).start()
interp.send("TOGGLE")
print(interp.active_state_ids)
# {'toggle.on'}
interp.stop()
Using Builder Style:
from xstate_statemachine import MachineBuilder, SyncInterpreter
machine = (
MachineBuilder("toggle")
.state("off", initial=True, on={"TOGGLE": "on"})
.state("on", on={"TOGGLE": "off"})
.build()
)
interp = SyncInterpreter(machine).start()
interp.send("TOGGLE")
print(interp.active_state_ids)
# {'toggle.on'}
interp.stop()
All four approaches produce identical runtime behavior. Choose the style that fits your project.
2οΈβ£ Your Second Machine: With Actions and Context
Letβs add logic β a counter that tracks how many times the switch is toggled:
from xstate_statemachine import create_machine, SyncInterpreter, MachineLogic
config = {
"id": "counter",
"initial": "active",
"context": {"count": 0},
"states": {
"active": {
"on": {
"INCREMENT": {"actions": "increment"},
"DECREMENT": {"actions": "decrement"},
"RESET": {"actions": "reset"}
}
}
}
}
class CounterLogic(MachineLogic):
def increment(self, interpreter, context, event, action_def):
context["count"] += 1
def decrement(self, interpreter, context, event, action_def):
context["count"] = max(0, context["count"] - 1)
def reset(self, interpreter, context, event, action_def):
context["count"] = 0
machine = create_machine(config, logic=CounterLogic())
interp = SyncInterpreter(machine).start()
interp.send("INCREMENT")
interp.send("INCREMENT")
interp.send("INCREMENT")
print(interp.context["count"])
# 3
interp.send("DECREMENT")
print(interp.context["count"])
# 2
interp.send("RESET")
print(interp.context["count"])
# 0
interp.stop()
π¦ Whatβs Included
| Component | Description |
|---|---|
| Runtime Library | Async (Interpreter) + Sync (SyncInterpreter) engines for executing state machines |
| Pythonic API | Define machines in pure Python β class-based, builder, or functional style |
| JSON Support | Full XState JSON format compatibility for cross-platform machine definitions |
CLI Tool (xsm) |
Generate production-ready Python from XState JSON with type hints and docstrings |
| Plugin System | Observable hooks for logging, metrics, debugging, and custom extensions |
| Snapshot System | Save and restore machine state for persistence, testing, and time-travel debugging |
| Diagram Export | Generate Mermaid or PlantUML diagrams from machine definitions |
πΊοΈ Feature Overview
Hereβs what XState-StateMachine supports β every feature youβd expect from a production statechart library:
States & Transitions
- Simple states β flat state machines with event-driven transitions
- Hierarchical (nested) states β compound parent states with child substates
- Parallel states β orthogonal regions running concurrently
- Final states β terminal states that emit
doneevents - Self-transitions β re-enter the same state (with timer reset)
Logic & Data
- Context β mutable data attached to the machine instance
- Guards β conditional transitions (
guardorcondkey) - Actions β side effects on transitions (
entry,exit,ontransition) - Services / Invoke β async or sync service calls with
onDone/onError - Delayed transitions β timer-based auto-transitions (
after) - Eventless transitions β auto-transitions based on conditions (
always)
Architecture
- Actor Model β spawn independent child machines with
spawn_prefix - Plugins β observable hooks for logging, metrics, custom extensions
- Snapshots β serialize/restore machine state with
take_snapshot()/restore_snapshot() - Dual interpreters β
Interpreter(async) andSyncInterpreter(sync)
Developer Tools
- CLI code generator β 5 templates from XState JSON
- Diagram export β Mermaid, PlantUML
- LoggingInspector plugin β built-in state transition logging
- Zero dependencies β pure Python standard library
Production & Safety
- Bounded inbox β
max_queue_sizewithOverflowPolicy.RAISE/BLOCK/DROP_NEWESTcaps how many events an interpreter will buffer. See Interpreters. - Strict mode β
strict=True(andstrictTargets) rejects unknown events or transition targets instead of silently ignoring them. See Interpreters. - Unhandled-event policy β
onUnhandled: "defer" | "error"controls what happens to events the machine never declared a handler for. See Core Concepts. - Action/guard error policy β
actionErrorPolicyandguardErrorPolicy(e.g."rollback") control how a raising action or guard affects the in-flight transition. See Actions. - Injectable Clock β
Clock,RealClock, andSimulatedClockletaftertimers and delayed sends run deterministically in tests. See Delayed Transitions.
π§βπ» Development Installation
To contribute or work from source:
git clone https://github.com/basiltt/xstate-statemachine.git
cd xstate-statemachine
uv pip install -e . --group dev --group lint --group test
This installs the library in editable mode with all development, linting, and testing dependencies.
Running the Tests
# Run all tests
python -m pytest tests/ -v
# Run specific test module
python -m pytest tests/tests_pythonic/ -v
# Run with coverage
python -m pytest tests/ --cov=xstate_statemachine --cov-report=html
ποΈ Project Structure
xstate-statemachine/
βββ src/xstate_statemachine/
β βββ __init__.py # Public API exports
β βββ machine.py # MachineNode, state tree, config parser
β βββ interpreter.py # Interpreter (async) + SyncInterpreter
β βββ pythonic.py # State, StateMachine, MachineBuilder, decorators
β βββ snapshot.py # Snapshot save/restore
β βββ plugin.py # Plugin base + LoggingInspector
β βββ diagram_exporter.py # Mermaid, PlantUML export
β βββ cli/ # CLI code generator
β βββ __main__.py # Entry point (xsm command)
β βββ extractor.py # JSON feature extraction
β βββ strategies/ # 5 code generation templates
βββ tests/ # 3,100+ tests
βββ docs/ # GitHub Pages documentation
βββ pyproject.toml
β¬οΈ Upgrading from Older Versions
pip install --upgrade xstate-statemachine
From v0.8.0 to v0.8.1:
A hardening release: every defect reported across three re-verification rounds, each reproduced before it was fixed and pinned by a regression test. Two changes are visible to existing code:
Receipthas four fields.send(wait=True)now resolves aReceipt(state_ids, changed, error, deferred). A positional destructure written for 0.8.0 βstate_ids, changed, error = receiptβ raisesValueError; read fields by attribute.asyncio.run_coroutine_threadsafe(interp.send(...), loop)is rejected withWrongThreadError(the thread check runs before the coroutine is scheduled). Useinterp.send_threadsafe(...)from other threads β this was already the documented path in 0.8.0.
Everything else is additive or a bug fix: new typed exceptions
(SnapshotMidStepError, SnapshotCorruptError, SnapshotSerializationError,
InvalidEventError β also a TypeError β and RootTargetError), new plugin
hooks (on_resolve_error, on_plugin_error), from_snapshot(clock=,
restart_timers=), and redaction in LoggingInspector. See
Whatβs New in 0.8.1 below.
From v0.5.x to v0.6.0:
v0.6.0 closes the remaining XState v5 feature gaps and repairs a family of
correctness defects. Existing JSON configs and MachineLogic patterns keep
working β but three behavioural changes are worth knowing:
-
Action errors are contained. If an action raises, the error is logged, the transition still completes, and the interpreter keeps running.
.send()no longer re-raises.β οΈ If you wrapped
send()intry/except, that handler will no longer fire. The machine advances as though the action succeeded β so a checkout machine can reportpaidwhen the charge actually raised. Two supported replacements: catch the error inside the action and record it oncontext(then guard on it), or register a plugin implementingon_action_errorto route failures to Sentry or a metric.In 0.5.0 the exception did propagate, but it also left the machine with an empty state configuration β permanently dead while still reporting
running. The old handler was catching an already-corrupted machine.See Actions.
- A stopped interpreter cannot be restarted.
start()afterstop()now raises instead of silently returning a dead instance. Build a new interpreter, or restore one withfrom_snapshot(). - Ambiguous state keys are rejected. A key containing
.whose first segment is also a sibling state (e.g."x.y"next to"x") now raises at parse time, because both resolved to the same id. Unambiguous dotted keys such as"v1.0"still work.
New in this release: built-in action creators
(assign, choose, enqueue_actions, sendTo, β¦), the
actor system with systemId, and the
pure API and waiting helpers.
From v0.4.x to v0.5.0:
- The Pythonic API (
State,build_machine,StateMachine,MachineBuilder) is new in v0.5.0. Existing JSON-based code continues to work unchanged. - The
--styleCLI flag is deprecated in favor of--template. Both still work, but--stylewill be removed in a future release. - All existing
create_machine()andMachineLogicpatterns remain fully supported.
From v0.3.x to v0.4.x:
- CLI tool introduced (
xsm generate-template) aftertransition support added toSyncInterpreter- No breaking changes
β‘ Async Support
If your project uses asyncio, you can use the async Interpreter instead:
import asyncio
from xstate_statemachine import create_machine, Interpreter
config = {
"id": "asyncToggle",
"initial": "off",
"states": {
"off": {"on": {"TOGGLE": "on"}},
"on": {"on": {"TOGGLE": "off"}}
}
}
async def main():
machine = create_machine(config)
interp = await Interpreter(machine).start()
await interp.send("TOGGLE", wait=True)
print(interp.active_state_ids)
# {'asyncToggle.on'}
await interp.stop()
asyncio.run(main())
Note: Plain
await interp.send(...)only awaits the event being enqueued β it does not wait for the macrostep to run. Passwait=Trueto get back aReceiptthat resolves once the transition has actually been processed (see Receipts and priority sends).
Tip: Use
SyncInterpreterfor scripts, CLI tools, and testing. UseInterpreterfor web servers, event loops, and real-time applications.
π Whatβs New in 0.8.1
The 0.8.1 release is the follow-through on 0.8.0: every finding from three independent re-verification rounds fixed, with the engines brought into lock-step. Highlights:
- Engine parity β the async
InterpreterandSyncInterpreternow agree on when a plain-syncinvokecompletes, what an unhandled child failure does to the parent, which hooks fire onstop()and on a send to a stopped machine, and the initon_transitionrecord. - Snapshots you can trust β a mid-macrostep snapshot
is refused (
SnapshotMidStepError) instead of persisting an inert machine; fired timers are persisted; malformed blobs raiseSnapshotCorruptError;from_snapshot(clock=, restart_timers=)andhas_dormant_timers. - Provenance, not names
β wildcards,
onUnhandledandstrictdecide βengine eventβ by who minted it (is_system_event), and the marker survivesdeepcopy,pickleandwait=True. - Observability β
on_event_droppedfires on both engines for every loss site with a typedreason; newon_resolve_errorandon_plugin_error;LoggingInspectorredacts secrets by default. - Build-time safety β a transition to the
machine root, a self-referential config, an ambiguous bare
stateInand a non-strevent are all typed errors now. maxIterationsis a chain budget β an external producer sending during a slow step is never charged to it, and engine completions are never cut by it.
See the full changelog for every change in this release.
π Whatβs New in 0.8.0
The 0.8.0 release closes a production-adoption audit spanning 34 defects across three waves. Highlights:
- Production Hardened β
actionErrorPolicy: "rollback"restores configuration and context after a raising action instead of committing a half-built transition. - Unhandled events, on purpose β
onUnhandled: "defer" | "error"replaces silently dropping events the machine never declared a handler for. - Strict mode β
strict=Truerejects an unknown event at thesend()call site, with a difflib suggestion, before itβs ever queued. - Bounded inbox β
max_queue_size=withOverflowPolicy.RAISE/BLOCK/DROP_NEWESTcaps how many events an interpreter will buffer. - Receipts and priority sends
β
send(wait=True)resolves aReceiptonce the macrostep runs, andsend(priority=True)jumps the inbox β no more pollingactive_state_ids. - Injectable Clock β
SimulatedClockdrivesaftertimers and delayed sends deterministically in tests; no more sleeping in your test suite. - Snapshot envelope v1 β persisted snapshots carry
a version, machine id, and structural hash, and
from_snapshot()can detect drift or restart services on restore.
See the full changelog for every change in this release.
β‘οΈ Next Steps
Now that youβre set up, explore the features:
- Quick Start β Build 5 different machine styles in detail
- Core Concepts β States, events, transitions, guards, actions
- Pythonic API β Define machines in pure Python (3 styles)
- JSON Configuration β XState JSON format reference
- Context β Working with machine data
- Guards β Conditional transitions
- Actions β Side effects and state mutations
- CLI Generator β Generate production-ready code from XState JSON
- Examples β Real-world patterns and advanced usage