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.12.0
You can also verify the CLI tool is available:
xsm --help
Expected output (trimmed; run xsm --help for the current text):
usage: xsm [-h] [-v] [--plain] [--no-color] [--no-anim] [--verbose]
{generate-template,gt,list-templates,lt,validate,val,paths,coverage,
info,plugins,update,snapshots,dlq,asyncapi,setup,inspect,ins,replay,
diagram,dia,simulate,sim,docs,new} ...
XState-StateMachine CLI β Generate Python code from XState JSON.
positional arguments:
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.
paths List a path to every reachable configuration of a
chart.
coverage Render a statechart coverage report (pytest --xsm-
coverage).
info Show library version, Python version, and feature
summary.
plugins List installed third-party plugins, stores and
brokers.
update Upgrade xstate-statemachine to the latest release on
PyPI.
snapshots List persisted snapshots in a store; --stale finds
instances written by another machine version.
dlq List, show, replay or purge dead-lettered messages.
asyncapi Generate an AsyncAPI 3.0 document from a machine.
setup Make the `xsm` command work on Windows machines that
block pip's xsm.exe launcher.
inspect (ins) Show a machine's state tree, transitions, logic and
policies.
replay Print or stream (--live) a recorded inspector session.
diagram (dia) Export a Mermaid, PlantUML or ASCII diagram.
simulate (sim) Run a machine live: pick events, advance the clock,
inspect context.
docs Generate a Markdown reference page per machine.
new Scaffold a minimal project from an example app.
options:
-h, --help show this help message and exit
-v, --version Show program's version number and exit.
--plain Plain text: no colour, no box glyphs, no animation
(implied when piped).
--no-color Keep layout and animation but emit no colour escapes.
--no-anim Disable spinners and in-place redraws.
--verbose Show the library's INFO log on stderr while running.
A bare xsm on a terminal opens an interactive launcher. See the CLI Tool guide for every command.
π Requirements
| Requirement | Details |
|---|---|
| Python | 3.9 β 3.15 (CI runs every version on Linux, with Windows and macOS spot-checks) |
| 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 a Python class (recommended):
from xstate_statemachine import State, StateMachine
class Toggle(StateMachine):
off = State(initial=True)
on = State()
toggle = off.to(on) | on.to(off) # the attribute name is the event: TOGGLE
with Toggle() as switch: # started, in `off`; stopped on exit
switch.toggle()
print(switch.state)
# on
switch.send("TOGGLE")
print(switch.state)
# off
The Quick Start walks through this style, and the Pythonic API page documents it in full. The styles below build the same machine.
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 (explicit events):
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 five 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 (and ASCII through xsm diagram) |
πΊοΈ 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
get_snapshot()/from_snapshot()(see Snapshots) - Dual interpreters β
Interpreter(async) andSyncInterpreter(sync)
Developer Tools
- CLI tool β generate (10 templates), inspect, simulate, diagram and document machines from XState JSON; interactive launcher on a terminal
- Diagram export β Mermaid, PlantUML (and ASCII through
xsm diagram) - 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/test_pythonic.py tests/test_pythonic_dsl_v2.py -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
β βββ models.py # MachineNode, StateNode, config parser, Mermaid/PlantUML export
β βββ factory.py # create_machine()
β βββ base_interpreter.py # Engine-agnostic semantics shared by both engines
β βββ interpreter.py # Interpreter (async)
β βββ sync_interpreter.py # SyncInterpreter (blocking)
β βββ pythonic.py # Public Pythonic API (re-exports the _pythonic_* modules)
β βββ _pythonic_class.py # StateMachine class DSL
β βββ _pythonic_builder.py # MachineBuilder
β βββ _pythonic_core.py # State, Transition, build_machine, decorators
β βββ machine_logic.py # MachineLogic container
β βββ logic_loader.py # Auto-discovery of actions, guards and services
β βββ actions.py # Built-in action creators (assign, raise, sendTo, ...)
β βββ plugins.py # PluginBase + LoggingInspector
β βββ clock.py # RealClock, SimulatedClock
β βββ graph.py # Graph algorithms (shortest_paths, reachable_states, ...)
β βββ helpers.py # wait_for, to_promise, pure transition()
β βββ testing_utils.py # Test helpers (stub_logic, ...)
β βββ persistence/ # Stores, locking, idempotency, snapshots, durable timers
β βββ patterns/ # RetryPolicy, dead_letter, CircuitBreaker, sagas
β βββ eda/ # CloudEvents envelope, outbox, dead letters, AsyncAPI
β βββ inspect/ # Live inspector (Stately Inspector protocol)
β βββ contrib/ # Optional extras: fastapi, django, sqlalchemy, redis, ...
β βββ cli/ # xsm: generate / inspect / simulate / diagram / docs / ...
β βββ __main__.py # Entry point (xsm command)
β βββ args.py # Argument parsing
β βββ commands/ # One module per sub-command
β βββ extractor.py # JSON feature extraction
β βββ strategies/ # 10 code generation templates
βββ tests/ # Unit, integration and docs tests (90% coverage gate)
βββ docs/ # GitHub Pages documentation
βββ pyproject.toml
β¬οΈ Upgrading from Older Versions
pip install --upgrade xstate-statemachine
From v0.11.x to v0.12.0:
State.to()/State.internal()no longer raise whenevent=is omitted; inside aStateMachinethe attribute name becomes the event (cycleβCYCLE). Compiling a transition that still has no event (functional API) raisesInvalidConfigErroratbuild_machine().- A
StateMachinesubclass is a running machine when instantiated. If you had a custom__init__, callsuper().__init__()(or keep usingcreate_machine(), which is unchanged). send,state,configuration,context,status,can,matches,allowed_events,machine,interpreter,engine,strict,plugins,validate_logicare reserved attribute names in aStateMachinebody; a state or transition using one raisesInvalidConfigError(keep the event name withevent=). A transition namedstartorstopis allowed but gets nosm.stop()sugar.- Undecorated methods whose names match a referenced guard / action / service are bound automatically; a method named
on_enter_<state>,on_<event>,before_<event>orafter_<event>is now a hook. Rename helpers that collide. - A declared
invoke.inputfactory now receives the triggering event, as in XState. A state that declares noinputstill passes the service{}(as XState passes nothing); declare"input": event_payloadto forward the event payload. A declared falsy input (0,"",False,{}) is now passed through. xstate_statemachine.pythonicre-exports only its own names; importMachineLogic,MachineNode,InvalidConfigErrorand the rest fromxstate_statemachine. Submodules are lazy, soxstate_statemachine.helpersis no longer an attribute after a bareimport xstate_statemachine; import it explicitly.self.status = ...(or any other reserved name) on aStateMachineinstance now raisesAttributeError, because those names are read-only properties of the instance faΓ§ade.
Engine behaviour that changed for existing charts (XState v5 conformance). None of these touch a chart that never depended on the old answer; tests that pin action order in parallel or nested charts may need new expected lists. Each is spelled out, with who notices and how to migrate, in the changelog:
- Exit actions run in reverse document order (descendants first; in a parallel state the last-declared region first); states are entered in document order.
- A transition into a sibling region of a parallel state re-enters every region;
reenter: truere-enters the source even when the target is its child. - Completing the machine at a top-level final state runs the remaining exit actions.
stop()still runs none. "*"and"prefix.*"handlers also receive engine events (done.invoke.*,error.platform.*,after.*,done.state.*).targetmay be a list of states in different regions of one parallel state (multi-target transitions).- Transitions for one event form one microstep: all exits, then all actions, then all entries; conflicting transitions are dropped.
done.state.*goes onto the internal queue, so a parentβsonDoneis taken before an external event that was already waiting.- Guards are evaluated in document order; several
assignactions of one transition compute against the same previous context; history is recorded when its owner exits;subscribe()listeners fire once per microstep.
From v0.10.x to v0.11.0:
The library core is additive: everything new (persistence, patterns, EDA, the
inspector and every contrib extra) is opt-in, and existing machines run
unchanged. One thing to know:
- Snapshot layout is v4 (
machine_version,deadlines). Older blobs upcast transparently on load; a v4 blob does not load on 0.10.x. See Snapshots.
From v0.8.0 to v0.9.0:
A hardening release: every defect reported across eleven re-verification rounds, each reproduced before it was fixed and pinned by a regression test. It is a minor bump because a handful of changes are visible to code that was correct on 0.8.0:
Receipthas five fields.send(wait=True)now resolves aReceipt(state_ids, changed, error, deferred, denied). A positional destructure written for 0.8.0 βstate_ids, changed, error = receiptβ raisesValueError; read fields by attribute.actionErrorPolicy: "fail"leavesstatus == "stopped", not"error", with the configuration cleared (#145). Check"stopped"orinterp.error is not None;"error"remains the status for a service that died.guardErrorPolicy: "raise"takes the fallback candidate before surfacing the exception (#152), instead of aborting the whole array.- Awaiting your own receipt inside an action raises
ReentrantWaitError(#219) instead of deadlocking; the sync engine refuses the same shape. Send withoutwait, or await the receipt from outside the step. - Unknown config keys are reported at every level (#216, #220) β a
WARNING by default,
InvalidConfigErrorunderstrict_config=True. A config carrying ad-hoc keys will start logging; move them undermetaor anx-prefix. 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.- Snapshot layout is v3. Older blobs upcast transparently on load; a v3 blob does not load on 0.8.0.
Everything else is additive or a bug fix: new typed exceptions
(RunawayChainError, ErrorEvent, SnapshotMidStepError,
SnapshotCorruptError, SnapshotSerializationError, InvalidEventError β
also a TypeError β and RootTargetError), new plugin hooks
(on_resolve_error, on_plugin_error, on_invocation_stranded,
on_chain_budget_exceeded, on_invalid_event, on_snapshot_error),
from_snapshot(clock=, restart_timers=, minimum_version=), sticky
chain_trips / last_chain_error, Interpreter(service_pool_size=),
MachineLogic(strict=True) and redaction in LoggingInspector. See
Whatβs New in 0.11.0, 0.10.0, 0.9.0 and 0.9.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.12.0
- Pythonic DSL, second generation β a
StateMachinesubclass is a running machine (sm = Light();sm.cycle();sm.state,sm.can(),sm.allowed_events), events are named by the attribute, naming-convention hooks (on_enter_<state>,before_<event>, β¦), signature-based argument injection, plain methods bound by name,@state.service,strict = True, build-time logic validation andfrom_state. See the Pythonic API. - XState v5 conformance β the statechart engine now follows XState v5 for exit/entry order, parallel transition domains,
reenter, multi-target transitions,done.state.*, wildcards and microsteps. The library reads XState v5 JSON; engine event names keep the v4 spelling (done.invoke.<id>). - Tooling β
xsm gt --fixtures, richer Mermaid/PlantUML (delayed, invoke, always and final-state edges), the[testing]model-based tester. The full list is the changelog.
π Whatβs New in 0.11.0
The durable, testable, web-ready release. The core stays zero-dependency;
everything that needs a third-party library is an explicit extra under
xstate_statemachine.contrib, each with a guide page that carries a
Guarantees box and a Threat model box.
- Persistence & durability (
xstate_statemachine.persistence, core) βMemoryStore/FileStore/SQLiteStorebehind onepersisted()load β act β persist β discard loop;OptimisticLock(version + jittered retry) or a fencedPessimisticLock, so two workers never lose an update;IdempotencyPlugin+ inbox (a replayed webhook gets the original receipt; scope is per tenant);AuditPlugin/TransitionLogPluginwithreplay();SnapshotMigratorfor in-flight instances across a deploy; durableaftertimers β deadlines persist as wall-clock instants,persisted()resumes the remaining time andDueTimerScannerwakes machines whose deadline passed while nothing was running. What is exactly-once and what is at-least-once is specified on the Guarantees page; the trust model on Security. - Resilience patterns (
xstate_statemachine.patterns, core) βRetryPolicy(exponential backoff + jitter as anafterdelay),dead_lettertransition helper,CircuitBreakerchart +@circuit_breaker. - Actor logic helpers β
from_callback,from_async_iterator,from_iterator,from_coroutine,from_callable,from_interpreter: XStateβsfromPromise/fromCallback/fromObservableparity as ordinary services, on both engines. - Graph algorithms β
shortest_paths/simple_paths/reachable_states/transition_coverage_targets, executed by the real engine (parallel regions, history and timers behave exactly as at runtime);xsm paths; an engine-backed reachability finding inxsm inspect. [pydantic]β typed context validated on every assign,EventModeldiscriminated unions βevent_schemas=,validate_machine_json(), JSON Schema.[redis]βRedisStore/RedisInbox/RedisLogwith fenced locks for multi-host deployments.[starlette]Β·[fastapi]Β·[litestar]βStatechartRegistryputs the persisted loop behind HTTP: receipt β200/202/409/422, principal-scopedIdempotency-Key, RFC 9457 problem responses that never carry exception text, SSE and WebSocket transition streams, lifespan-managed timers.StatechartRoutergenerates one route per event with OpenAPI built from your Pydantic events;Depends(get_interpreter(...))for hand-written handlers. Closed by default:authorize=is required. Start with the FastAPI guide and the runnableexamples/integrations/fastapi_ordersapp.[sqlalchemy]Β·[flask]βStatechartTypecolumn +StatechartMixin(send()underSELECT β¦ FOR UPDATEor an optimistic version),SQLAlchemyStore/ inbox / log / outbox sharing your session, Alembic helpers; a Flask extension + blueprint (onePOSTper event, CSRF-aware forms,flask xsmCLI) and Quart parity. Example appssqlalchemy_ordersandflask_wizard. See SQLAlchemy and Flask.[django]Β·[drf]Β·[channels]βStatechartField(JSON snapshot plus indexed_state/_versioncolumns andstate__inlookups),StatechartModelMixin.send()insidetransaction.atomic()+select_for_update()(or optimistic +send_with_retry),DjangoStore, durable deadlines viamanage.py xsm_deadlines;pre_transition/post_transitionsignals,TransitionLogwritten in the same transaction,PermissionGuard/has_event_permission, aDjangoOutboxStore; an admin mixin with CSRF-protected transition buttons (permission re-checked on POST, confirm + reason, history inline) andxsm_*management commands; a DRF viewset mixin (one@actionper event, explicitpermission_classesrequired, user-scopedIdempotency-Key, drf-spectacular schema) and a Channels consumer re-authorised on every push;xsm_migrate_fsmmigrates adjango-fsm-2model mechanically. Example appdjango_approvals. See Django and DRF & Channels.[testing]β a pytest plugin withxsm_*fixtures, aSimulatedClockfixture, state and transition coverage with--xsm-fail-under-state-coverage/--xsm-fail-under-transition-coverage, anxsm_pathfixture that walks every engine-verified path, Hypothesis strategies for events and paths,replay()/assert_replay_consistent()for audit logs, in-memory fake brokers. See Testing.- Event-driven architecture (
xstate_statemachine.eda, core) β CloudEvents 1.0Envelopewith correlation / causation ids,BrokerAdapterprotocols,InboundDispatcher(per-subject order, inbox dedup, poison β dead letters), a transactional outbox (OutboxPlugincommits with the snapshot,OutboxRelaypublishes),SQLiteDeadLetterStore+xsm dlq list|show|replay|purge,SagaBuilder/ChoreographyRouter,xsm asyncapi,[cloudevents]interop. See EDA. - Brokers & Celery β
[kafka],[rabbitmq],[nats],[sqs]and Redis Streams ([redis]) adapters, each passing one contract suite and a real-broker restart test, with the brokerβs redelivery count carried on the envelope so poison reaches the DLQ across restarts;[celery]givescelery_service(aninvokebridge),@statechart_task,DurableTimerSchedulerfor Beat and an outbox relay task; pickle and YAML serializers are refused. See Brokers and Celery. [observability]+ live inspector β OpenTelemetry spans and Prometheus metrics with label hygiene (state and event names from the chart allow-list, never payloads or keys), structlog / loguru / Sentry hooks,gen_ai.*spans for agents; a stdlibInspectorPluginspeaking the Stately Inspector wire protocol with SSE, JSONL and replay sinks andxsm inspect --live. See Observability and Inspector.[agents]β aTOOL_LOOPchart where the model proposes and the machine decides: per-state tool allow-lists,run_toolsafety, budgets and timeouts as guards, durable human approval, structured output with retry, LangGraph / pydantic-ai interop,AgentTracePlugin. Example appagents_support_bot. See Agents and vs LangGraph.- Recipes, comparisons, adoption kit β eight tested recipes
(Stripe webhooks, APScheduler, RQ, slot filling, β¦); comparison pages vs
transitions, python-statemachine, django-fsm and LangGraph;
xsm newtemplates, Stately export and the integrations journey. - 1.0 hardening β explicit plugin discovery (
plugins.discover, entry-point groups incl.xstate_statemachine.brokers), a proven compatibility matrix (oldest and newest of every extra on Python 3.9 and 3.13), a deprecation policy and the security baseline (X0) re-verified per group. - Core prerequisites β snapshot layout v4 (
machine_version,deadlines),interpreter.wall_now(), a global plugin registry, thecontext_validatorseam, the__xstate_event__adapter,SyncInterpreter.send_threadsafe(),MachineNode.version. - Programme baseline β
SECURITY.md, apip-auditCI job over every shipped extra, SHA-pinned Actions, a no-pickle/evaltree guard, and measured performance budgets (benchmarks/budgets.json, nightly).
See the Changelog for the full list.
π Whatβs New in 0.10.0
A CLI release. The library is unchanged (one additive create_machine(...,
strict_config=) overload), and xsm grows from a code generator into a
terminal toolkit β still with zero runtime dependencies:
- Interactive launcher β a bare
xsmon a terminal opens a menu with recent files and a generate wizard that previews the module before writing. xsm inspectβ state tree, transitions table, logic to implement and failure policies for a machine, built with the real library.xsm simulateβ run a machine live on aSimulatedClock: pick enabled events, fire timers, flip guards, undo, print history and snapshots. The same engine replays--events A,+500,B,--script file.jsonand emits--jsonfor CI.xsm diagramandxsm docsβ Mermaid / PlantUML / ASCII diagrams and a Markdown reference page per machine, to stdout or files.- Companion templates β
--with-tests(a pytest module recorded from the engine),--with-types(TypedDictcontext,Literalevents, typed stubs) and--with-plugin(aPluginBasewired for the hooks the chart can fire), alongside any primary template or on their own via--template. xsm validatenow builds withcreate_machine(strict_config=True), reports unreachable states and the libraryβs own warnings, and has--jsonand--lenient.xsm updateβ upgrade to the latest release with the installer that installed you (pip / pipx / uv tool); refuses editable and conda installs with the right command instead.--checkfor scripts.- Windows:
xsmwhere pipβs launcher is blocked β machines governed by Application Control / AppLocker / Smart App Control refuse the unsignedxsm.exepip generates. Runpython -m xstate_statemachine setuponce andxsmworks normally (--check,--undo);python -m xstate_statemachine β¦always works. - Presentation β truecolor/256/16-colour tiers, box-drawing, spinners and
step lists on a terminal; deterministic plain text when piped or with
--plain;--no-color/NO_COLOR,--no-anim,--verbose;--jsononvalidate,inspect,simulate,list-templatesandinfo.
See the CLI Tool guide.
π Whatβs New in 0.9.1
A patch release from the thirteenth re-verification round. Nothing changes for code that was correct on 0.9.0; two additions are worth knowing about:
drain_pending()now returns every pending event on the async engine β the priority lane (fired timers, completions,send_priority()) as well as the inbox. Before, the documented drain β persist βstop()recipe silently lost the lane.on_interpreter_startfires on a restored interpreter too; readinterpreter.restored_from_snapshotto tell resume from bring-up.- Restored
last_chain_erroris aRestoredChainErrorβ aRunawayChainErrorand aRestoredErrorβ so anisinstanceguard survives a restart. Malformedchain_trips/last_chain_errorin a blob areSnapshotCorruptError. interpreter.dropped_receiptsand theon_receipt_droppedhook are the gateable form of the dropped-receiptRuntimeWarning.SyncInterpreter(max_queue_size=, overflow_policy=)are accepted for parity and refused with aValueErrorβ the sync engine has no inbox to bound.re_mint(event, **fields)patches a field of an engine-minted event and keeps its provenance;_replaceremains a one-way demotion.benchmarks/production_characteristics.py --jsonfor CI gates; releases carry PEP 740 attestations (pypi-attestations verify pypi β¦).
π Whatβs New in 0.9.0
The 0.9.0 release is the follow-through on 0.8.0: every finding from eleven 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 β One recommended path (a Python class), then XState JSON, engines and next steps
- Core Concepts β States, events, transitions, guards, actions
- Pythonic API β Define machines in pure Python (class, builder, functional)
- Coming from python-statemachine and Coming from transitions β Port a worked machine, concept by concept
- JSON Configuration β XState JSON format reference
- Context β Working with machine data
- Guards β Conditional transitions
- Actions β Side effects and state mutations
- CLI Tool β Generate, inspect, simulate and document machines from XState JSON
- Examples β Real-world patterns and advanced usage