Skip to main content
xstate-statemachine — Make every event count. Visual statecharts. Native Python.

xstate-statemachine

State machines and statecharts for Python — define them in plain Python or load XState JSON.

PyPI Python versions Downloads CI Docs License

pip install xstate-statemachine — zero runtime dependencies, py.typed, 90% coverage gate enforced in CI.

Find your way: 60-second start · XState JSON & Stately · Why a statechart? · A tour in Python · How it compares · What's in the box · For LLM agents · Install · Cookbook · Docs


⚡ 60-second start

from xstate_statemachine import State, StateMachine

class TrafficLight(StateMachine):
    green = State(initial=True)
    yellow = State()
    red = State()
    cycle = green.to(yellow) | yellow.to(red) | red.to(green)  # event CYCLE

    def on_enter_red(self):  # naming-convention hook
        print("Don't move.")
    def on_cycle(self, amount=1):  # only the args you name are injected
        return f"counted {amount}"

with TrafficLight() as light:  # started in `green`; stopped on exit
    print(light.cycle(amount=3))  # counted 3
    print(light.state, light.red.is_active)  # yellow False
    light.cycle()  # Don't move.

A transition's attribute name is its event (cycle → CYCLE); hooks are found by name (on_enter_<state>, on_exit_<state>, on_<event>, before_<event>, after_<event>); an event the current state does not accept is ignored. Inside the block you can also ask:

light.can("CYCLE"), sorted(light.allowed_events)  # True ['CYCLE']
light.configuration, light.context, light.status  # frozenset({'red'}) {} running (inside the with block)
light.send("CYCLE", amount=2)  # the same as light.cycle(amount=2)

📝 create_machine() still returns the bare MachineNode to drive with your own interpreter (snapshots, custom clocks, actor systems):

interp = SyncInterpreter(TrafficLight.create_machine()).start()

🧭 The same chart as XState JSON

Every Python machine compiles to XState JSON, and XState v5 JSON loads here with the same statechart semantics (engine event names keep the v4 spelling, such as done.invoke.<id>):

from xstate_statemachine import State, StateMachine

class Toggle(StateMachine):
    machine_id = "toggle"
    inactive, active = State(initial=True), State()
    toggle = inactive.to(active) | active.to(inactive)

print(Toggle.create_machine().to_json())
# {"id": "toggle", "states": {"inactive": {"on": {"TOGGLE": {"target": "active"}}}, ...
from xstate_statemachine import SyncInterpreter, create_machine

machine = create_machine({"id": "toggle", "initial": "inactive", "states": {
    "inactive": {"on": {"TOGGLE": "active"}}, "active": {"on": {"TOGGLE": "inactive"}}}})
light = SyncInterpreter(machine).start()
light.send("TOGGLE")
print(light.current_state_ids)  # {'toggle.active'}
light.send("BANANA")  # not legal here: ignored
toggle: inactive ⇄ active on TOGGLE
🔗 Open this machine in the Stately editor — a Stately account is needed there.

Prefer a loud failure? strict=True raises at the call site:

strict = SyncInterpreter(machine, strict=True).start()
strict.send("BANANA")  # UnknownEventError

What is XState / Stately, and why would I care? XState is the most widely used statechart library in JavaScript; its JSON format describes states, events, guards, timers and services with SCXML semantics, and Stately is a visual editor for it. You need neither. If you use them, the whole team reviews one diagram while Python executes it, and xsm generate-template turns a drawn chart into typed Python stubs and tests (CLI). The JSON names its guards and actions; Python supplies them:

from xstate_statemachine import MachineLogic, SyncInterpreter, create_machine

config = {"id": "door", "initial": "closed", "context": {"opens": 0, "locked": False},
          "states": {"opened": {"on": {"CLOSE": "closed"}}, "closed": {"on": {"OPEN": {
              "target": "opened", "guard": "isUnlocked", "actions": "countOpen"}}}}}
logic = MachineLogic(
    guards={"isUnlocked": lambda ctx, event: not ctx["locked"]},
    actions={"countOpen": lambda interp, ctx, event, action: ctx.update(opens=ctx["opens"] + 1)},
)
door = SyncInterpreter(create_machine(config, logic=logic)).start()
door.send("OPEN")
print(door.current_state_ids, door.context)  # {'door.opened'} {'opens': 1, 'locked': False}

🧠 Why a statechart?

Every non-trivial flow starts as a few booleans — and four booleans are sixteen combinations:

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

A statechart lists the states that exist and the only legal moves between them, so the impossible combinations cannot be constructed:

class Fetch(StateMachine):
    idle, loading, failed, done = State(initial=True), State(), State(), State(final=True)
    fetch, ok = idle.to(loading), loading.to(done)
    error, retry = loading.to(failed), failed.to(loading)
Bug With booleans With a statechart
🕳️ Impossible states is_loading and is_error both true Exactly one state per region
👻 Zombie callbacks A late API response lands after the user cancelled cancelled does not handle it, so it is discarded
🔁 Double submission A second click before the first finishes submitting has no SUBMIT transition — the click does nothing

In a distributed system those become redelivered messages and retried webhooks, which the persistence layer handles the same way.

🐍 A tour in Python

Context, guards and payloads. A guard is a named condition; callbacks declare only the arguments they want.

from xstate_statemachine import State, StateMachine, guard

class Cart(StateMachine):
    initial_context = {"items": 0}
    shopping, paid = State(initial=True), State(final=True)
    add = shopping.to(shopping)  # event ADD
    checkout = shopping.to(paid, guard="has_items")

    def on_add(self, context, qty=1):  # payload fields arrive by name
        context["items"] += qty
    @guard
    def has_items(self, context):
        return context["items"] > 0

with Cart() as cart:
    cart.checkout()  # the guard refuses: nothing happens
    cart.add(qty=2)
    cart.checkout()
    print(cart.state, cart.context)  # paid {'items': 2}

Nested states. A transition on the parent applies to every child: write logout once.

from xstate_statemachine import State, StateMachine

class Session(StateMachine):
    logged_out = State(initial=True)
    class authenticated(State.Compound):
        browsing, paying = State(initial=True), State()
        checkout = browsing.to(paying)
    login = logged_out.to(authenticated)
    logout = authenticated.to(logged_out)  # from EVERY substate

with Session() as session:
    session.login()
    session.checkout()
    print(session.state)  # authenticated.paying
    session.logout()
    print(session.state)  # logged_out

Parallel regions. Regions run independently; on_done fires once, when all are final.

from xstate_statemachine import State, StateMachine

class Release(StateMachine):
    class checks(State.Parallel, initial=True, on_done="deployed"):
        class build(State.Compound):
            compiling, built = State(initial=True), State(final=True)
            build_ok = compiling.to(built)
        class lint(State.Compound):
            linting, clean = State(initial=True), State(final=True)
            lint_ok = linting.to(clean)
    deployed = State(final=True)

with Release() as release:
    release.lint_ok()
    release.build_ok()  # both regions final -> on_done
    print(release.state, release.status)  # deployed done

History. Return to the step you left, not to the first one.

from xstate_statemachine import State, StateMachine

class Wizard(StateMachine):
    class form(State.Compound, initial=True):
        name, address = State(initial=True), State()
        resume = State(history="shallow")
        next = name.to(address)
    help = State()
    ask, back = form.to(help), help.to(form.resume)

with Wizard() as wizard:
    wizard.next(), wizard.ask(), wizard.back()
    print(wizard.state)  # form.address

Timers. after fires only if the machine is still in the state; tests advance a SimulatedClock.

from xstate_statemachine import SimulatedClock, State, StateMachine, SyncInterpreter

class Login(StateMachine):
    active = State(initial=True, after={30_000: "expired"})  # 30 s
    expired = State(final=True)

clock = SimulatedClock()
interp = SyncInterpreter(Login.create_machine(), clock=clock).start()
clock.increment(30_000)  # instant, no sleep
print(interp.current_state_ids)  # {'Login.expired'}

Delays can be named and computed at runtime, which is how exponential backoff is spelled:

logic = MachineLogic(delays={"BACKOFF": lambda ctx, event: 2 ** ctx["attempt"] * 1000})

Services. invoke runs work while a state is active: return → onDone, raise → onError.

from xstate_statemachine import State, StateMachine, action, service

class Profile(StateMachine):
    initial_context = {"user": None}
    idle, ready, failed = State(initial=True), State(final=True), State()
    loading = State(invoke={"src": "fetch_user", "onError": "failed",
                            "onDone": {"target": "ready", "actions": "save_user"}})
    load = idle.to(loading)

    @service
    def fetch_user(self, context):
        return {"name": "Ada"}
    @action
    def save_user(self, context, data):  # `data` is the service's result
        context["user"] = data

with Profile() as profile:
    profile.load()
    print(profile.state, profile.context)  # ready {'user': {'name': 'Ada'}}

Strict mode, contained failures, plugins. strict = True raises on an event that cannot be taken. An action that raises is logged and reported to plugins; the transition completes and the machine runs on.

from xstate_statemachine import PluginBase, State, StateMachine, TransitionNotAllowedError

class Door(StateMachine):
    strict = True
    closed, opened = State(initial=True), State()
    open, close = closed.to(opened), opened.to(closed)
    def on_open(self):
        raise RuntimeError("sensor offline")

class Alerts(PluginBase):  # every lifecycle hook is optional
    def on_action_error(self, interpreter, action, error):
        print("report:", error)

with Door(plugins=[Alerts()]) as door:
    door.open()  # report: sensor offline
    print(door.state)  # opened
    try:
        door.open()
    except TransitionNotAllowedError as exc:
        print(exc)  # Can't 'OPEN' when in 'opened': ...

Built-in plugins log every event, transition and action, or trace them with OpenTelemetry:

from xstate_statemachine import LoggingInspector
interp.use(LoggingInspector())

async. Any async def callback puts the instance on the asyncio engine (the Interpreter).

import asyncio
from xstate_statemachine import State, StateMachine

class Fetch(StateMachine):
    idle, done = State(initial=True), State()
    fetch = idle.to(done)
    async def on_fetch(self, url):
        await asyncio.sleep(0)
        return url.upper()

async def main():
    async with Fetch() as sm:
        print(await sm.fetch(url="http://x"))  # HTTP://X

asyncio.run(main())

Snapshots, diagrams, the pure API. Continuing the JSON toggle above — resume where it left off, draw it with no graphviz install, ask "what would happen?" without running anything:

snapshot = light.get_snapshot()  # a JSON string: Redis, Postgres, a file...
resumed = SyncInterpreter.from_snapshot(snapshot, machine)
print(resumed.current_state_ids)  # {'toggle.active'}
print(machine.to_mermaid())  # also to_plantuml(); `xsm diagram` on the CLI
from xstate_statemachine import initial_transition, pure_transition

start, _ = initial_transition(machine)
after, actions = pure_transition(machine, start, "TOGGLE")
print(after.state_ids, actions)  # {'toggle.active'} []  <- nothing ran

Most apps never call from_snapshot by hand: persisted() wraps load → act → save under an optimistic version check, with memory, file, SQLite, Redis, SQLAlchemy and Django stores.

from xstate_statemachine import create_machine
from xstate_statemachine.persistence import MemoryStore, persisted

machine = create_machine({"id": "order", "initial": "cart", "states": {
    "cart": {"on": {"PAY": "paid"}}, "paid": {"type": "final"}}})
store = MemoryStore()  # or SQLiteStore("orders.db"); RedisStore is contrib.redis ([redis] extra)
with persisted(store, "order:42", machine) as order:  # loaded or created
    order.send("PAY")  # saved on exit; a racing writer gets ConflictError
print(store.load("order:42").version)  # 1
with persisted(store, "order:42", machine) as order:  # later, in another worker
    print(order.current_state_ids)  # {'order.paid'}

Testing is plain Python — the [testing] extra adds pytest fixtures, path walking and statechart coverage when you want them:

def test_cycle_goes_to_yellow():
    with TrafficLight() as light:
        light.cycle()
        assert light.state == "yellow"

A fluent MachineBuilder and a functional build_machine() compile to the same machine — see the Pythonic API guide.

⚖️ How it compares

Which library should I use? The honest short answer:

  • Pick python-statemachine if you want a beautiful, pythonic declarative API and don't need XState JSON interop — a substantive alternative with compound, parallel and history states.
  • Pick transitions if you want a mature, flexible FSM attached to an existing class.
  • Pick this library when you want a Python class and XState/Stately JSON for the same chart, or invoke, after, actors, persistence and framework adapters as first-class primitives.

Source-noted comparisons: python-statemachine · transitions · django-fsm · LangGraph · Burr · @statelyai/agent · AWS Step Functions. Speed: benchmarks/competitors/ and Production characteristics.

🧰 What's in the box

Feature What you get Guide
Hierarchy, parallel, history Compound states, concurrent regions, shallow/deep history, final states Hierarchical · Parallel
Timers, services, actors after with fixed or computed delays; invoke with onDone / onError; spawned child machines and a systemId registry Delayed transitions · Services · Actors
Persistence & durable timers Stores, locks, idempotency, after deadlines that survive restarts, audit log, migrations Persistence · Guarantees
Integrations FastAPI · Flask · Litestar · Starlette · Django · DRF & Channels · SQLAlchemy · Redis · Celery · Pydantic — closed by default Integrations · Extras
Event-driven architecture Kafka, RabbitMQ, NATS, SQS, Redis Streams; outbox, dedup, dead letters, sagas, AsyncAPI EDA · Brokers
Observability LoggingInspector, lifecycle plugins, OpenTelemetry, Prometheus, the live Stately Inspector Observability · Plugins
Testing Pure transition API, pytest fixtures, path walking, statechart coverage, replay Testing · Pure API
CLI xsm Typed code and tests from JSON, CI / pre-commit validation, diagrams, simulate, inspect, app scaffolds CLI · Templates
LLM agents The model proposes, the chart decides: tool allow-lists, budgets, durable human approval Agents
Production Failure semantics, threading, trust model, every error explained, runnable example apps and recipes Reliability · Security · Troubleshooting · FAQ · Examples

🤖 For LLM agents

An agent loop is a statechart the model does not get to rewrite. With the [agents] extra the model is one invoked service that proposes tool calls and the chart decides: per-state tool allow-lists, budgets as guards, durable human approval. FakeModel runs it offline in CI — guide, vs LangGraph.

# pip install "xstate-statemachine[agents]"   # … the extra's only hard dependency is pydantic
import asyncio
from xstate_statemachine.contrib.agents import FakeModel, run_agent, tool_registry

def get_weather(city: str) -> str:
    """Current weather for a city."""
    return f"sunny in {city}"

model = FakeModel([{"tool": "get_weather", "args": {"city": "Kochi"}}, {"text": "Sunny."}])
tools = tool_registry(get_weather)
res = asyncio.run(run_agent(model, tools=tools, prompt="Weather?", max_turns=5))
assert res.final_state == "toolLoop.done" and res.usage["turns"] == 2

🚀 Install

pip install xstate-statemachine                # or: uv add xstate-statemachine
pip install "xstate-statemachine[fastapi]"     # extras: [django] [redis] [kafka] [agents] [testing] ...
pip install "xstate-statemachine[all]"         # every shipped integration and testing extra

On Windows, if an Application Control policy (WDAC / AppLocker) blocks xsm.exe, run python -m xstate_statemachine setup once, or call python -m xstate_statemachine directly (details).

Supported Python: 3.9 – 3.15 (CPython), tested on Linux plus Windows and macOS spot-checks on every PR. Framework versions per extra: Compatibility.

Semantic Versioning. From 1.0, the core API, JSON format, xsm CLI and persistence layer (snapshot layout included) follow Semantic Versioning: breaking changes only in a new major. The 0.x series is pre-1.0; contrib extras remain provisional. Deprecations warn first — see the Deprecation Policy.

📚 Cookbook

CI-tested solutions, each a runnable folder under examples/recipes/. All recipes →

🤝 Contributing and license

Issues and PRs are welcome — see CONTRIBUTING.md and AGENTS.md. Every Python block in this README and the guides is executed in CI.

Documentation · API reference · Changelog · PyPI · Security policy · MIT License

Metadata

Release files for xstate-statemachine 0.12.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.12.1
File Size Uploaded Provenance Actions
xstate_statemachine-0.12.1.tar.gz 6.6 MB With provenance Details for xstate_statemachine-0.12.1.tar.gz

Built distribution (wheel)

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

Total release size: 8.0 MB

Release files / xstate_statemachine-0.12.1.tar.gz

Download URL xstate_statemachine-0.12.1.tar.gz
Size 6.6 MB
Tags Source
SHA-256 checksum
How to use checksums
e71360d02feee382e3e3f1974fa5f419b91548073c5aaf573a4ef69980d1efff
BLAKE2b-256 checksum
How to use checksums
470678f05e989e832b71aa8897d971ae71ddf04816d4a11fbb387edeceeed660
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 Nov 7, 2026.

Transparency log

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

Download URL xstate_statemachine-0.12.1-py3-none-any.whl
Size 1.4 MB
Tags Python 3
SHA-256 checksum
How to use checksums
a6b74a0a8a4bf7ab4361cdc745bf259983a4c63605e3228745a3da95c84f74c5
BLAKE2b-256 checksum
How to use checksums
3f221a046fcb760bca96f39b30d81736c807efa5f5dd1db89d7cf3289bf054c8
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 Nov 7, 2026.

Transparency log

Security for xstate-statemachine 0.12.1

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.

Full provenance

Both files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for xstate_statemachine-0.12.1.tar.gz

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 Nov 7, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

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