Outcome focus: Reader understands what xstate-python is trying to make possible, where it fits against other Python state-machine libraries, and why SCXML run-to-completion semantics, actors, clocks, and context snapshots matter for production workflows.
xstate-pythonstate machinespythonstatechartsscxml
The useful thing about xstate-python is not that it makes Python look like TypeScript.
The useful thing is that it treats the statechart as a shared workflow contract.
That distinction matters. A lot of workflow code starts as a few booleans, becomes a reducer, grows a retry path, picks up one background task, and eventually turns into an unspoken protocol between frontend, backend, tests, and operations. The chart exists, but it is smeared across handlers and comments. Nobody can export it. Nobody can run the same artifact in another runtime. Nobody can ask whether an event is legal without executing a pile of application code.
The README for xstate-python has a narrow goal: load native XState or Stately JSON in Python and run it with an engine shaped around the W3C SCXML execution algorithm. That makes the chart portable. The state structure remains declarative data. Python supplies only the things that have to be live: actions, guards, delays, and actor logic.
That is the right boundary.
Keep the Chart Data-Only#
The central API is intentionally small:
import json
from xstate import Machine
with open("machine.json") as f:
config = json.load(f)
machine = Machine(config)That snippet is more important than it looks. It says the workflow can be authored outside Python, reviewed as JSON, shared with a JavaScript runtime, exported from a visual tool, and then interpreted by a Python service without translating the state graph by hand.
The Python code should not be where the chart's structure hides. The Python code should be where the chart's implementation hooks live:
machine = Machine(
config,
guards={"hasQuery": lambda ctx, event: bool(event.data.get("query"))},
actions={"rememberQuery": assign({"query": lambda _ctx, event: event.data["query"]})},
delays={"debounce": 300},
actors={"fetchResults": from_promise(lambda input: ["Ada", "Grace"])},
)That separation is the project thesis in miniature. The JSON names the states, transitions, guards, delays, and actors. Python resolves those names to real behavior. This keeps the workflow visible and keeps the implementation honest.
It also creates a useful review habit: if a pull request changes the chart, reviewers can inspect the workflow. If a pull request changes a guard or action, reviewers can inspect behavior behind a named boundary. The two concerns stop pretending to be the same file.
Why SCXML Semantics Matter#
State machines are easy until order matters.
What happens when an event triggers an exit action, that action raises another event, an always transition becomes eligible, and a delayed transition is also due? In a hand-rolled machine, the answer is often "whatever the current implementation happens to do." That is not a contract. That is folklore.
SCXML gives the runtime a more disciplined execution model: microsteps, macrosteps, event processing, enabled transitions, exit sets, entry sets, and run-to-completion behavior. You do not need every engineer on the team to quote the spec. You do need the runtime to have a consistent answer when transitions interact.
That is why the README's SCXML emphasis is not academic decoration. A machine with hierarchy, parallel regions, history states, eventless transitions, delayed transitions, and onDone behavior needs a real execution model. Without one, "statechart" slowly becomes "nested switch statement with vibes."
The repository already leans into that seriousness. It exposes SCXML XML import through xstate.scxml.scxml_to_machine(...), verifies against the SCXML test framework, and names the remaining conformance gap instead of smoothing it over. That is exactly how an alpha project should talk: useful, explicit, and honest about the edge.
Actors Are the Side-Effect Boundary#
The most interesting part of the current README is the actor surface.
XState v5 treats running logic as actors. xstate-python now supports machine actors, promise actors, callback actors, observable actors, spawning, parent-child messaging, and invoke lifecycle wiring. That is the piece that moves the project from "pure transition helper" toward "workflow runtime."
The useful pattern is invoke: start a child actor when a state is entered, then transition on onDone or onError.
from xstate import Machine, assign, create_actor, from_promise
def fetch_user(input):
return {"id": input["user_id"], "name": "Ada"}
machine = Machine(
{
"id": "fetcher",
"context": {"user_id": 42, "user": None},
"initial": "loading",
"states": {
"loading": {
"invoke": {
"id": "getUser",
"src": "fetchUser",
"input": lambda ctx, _event: {"user_id": ctx["user_id"]},
"onDone": {
"target": "success",
"actions": [assign({"user": lambda _ctx, event: event.data})],
},
"onError": "failure",
}
},
"success": {"type": "final"},
"failure": {},
},
},
actors={"fetchUser": from_promise(fetch_user)},
)
actor = create_actor(machine).start()This is the boundary that plain async functions tend to blur. The side effect is not "some await inside some handler." It is actor logic attached to a state lifecycle. The parent state determines when the actor starts, how the result is interpreted, and where errors flow.
That matters for retries, approvals, timers, loading states, cancellation, and agent tool workflows. The moment a workflow says "run this thing while I am in this state, then route based on the outcome," it is actor-shaped.
Clocks Make Time Testable#
Delayed transitions are where workflow tests often become flaky.
The README's SimulatedClock example is small, but it shows a production-quality instinct:
clock = SimulatedClock()
service = interpret(machine, clock=clock).start()
clock.increment(1000)
assert service.state.value == "done"That is the difference between testing time and waiting for time. A delayed transition should be deterministic in tests. A retry backoff should be advanced by the test, not by the wall clock. A traffic-light example with parallel regions should be reproducible down to which timers fire after each increment.
The same idea applies to agent and data workflows. If a tool approval expires after fifteen minutes, the test should advance fifteen minutes. If a retry budget has three attempts, the test should assert each attempt without sleeping. When time is part of the workflow, the clock is part of the interface.
Python Choices That Matter#
The package metadata tells a coherent story:
| Decision | What it says |
|---|---|
| Package name | xstate |
| Version | 0.5.0 |
| Runtime floor | Python 3.13+ |
| Runtime dependencies | none |
| Status | alpha |
| Development tools | Poetry, pytest, Ruff, mypy |
| Current focus | XState v5 alignment, actors, setup, and SCXML correctness |
The "no runtime dependencies" choice is especially meaningful. A state-machine runtime sits low in the stack. The fewer dependencies it brings into a service, the easier it is to justify using it in backend workflows, CLI tools, data jobs, and agent harnesses.
The context model is also pointed in the right direction. Context is snapshot-isolated by default, public snapshot containers are immutable at the boundary, and immutable dataclass contexts get explicit support through dataclass_context() and DataclassContextAdapter. That fits modern Python: typed domain data, explicit mutation through assign(...), and snapshots that are safer to hand to subscribers.
The async story is deliberately split. Pure transition and guard logic remains synchronous. The async runtime handles action execution, timers, and actor settlement. That is a sane boundary because it keeps the transition algorithm deterministic while still letting real services await network work, queues, and callback-style actors.
What This Is Not#
The honest caveat is that xstate-python is not a durable workflow engine.
It can make workflow states explicit. It can run transitions consistently. It can bind side effects to actor lifecycles. It can make timers testable. It does not, by itself, persist a workflow across worker restarts or replay a run from a checkpoint.
That means the decision tree from State Machines in Python: from xstate-python to LangGraph still applies. If the hard requirement is durable execution, reach for LangGraph or Temporal first. If the hard requirement is Python-native statechart ergonomics without XState compatibility, compare python-statemachine, transitions, and Sismic. If the hard requirement is "this chart should round-trip with the XState and Stately ecosystem," then xstate-python becomes interesting.
It is also still alpha. The README says the branch is pre-release, PyPI release is pending, and snapshot serialization is not implemented yet. That is not a reason to dismiss it. It is a reason to use it where its current contract is enough and contribute where the missing contract matters.
Where I Would Use It#
I would reach for this library in four cases.
First, shared frontend/backend workflows. If a product flow is modeled in XState on the frontend and the backend needs to validate or simulate the same legal transitions, a Python runtime for the same chart shape is valuable.
Second, testable operational workflows. A traffic-light intersection, document review flow, claim intake process, retrying fetch, onboarding sequence, or batch job with guarded steps can all benefit from explicit states and deterministic clocks.
Third, agent control planes. Agent tools are state-machine-shaped: pending approval, approved, running, retrying, failed, cancelled, completed. Keeping those states explicit is safer than burying them in a long async function.
Fourth, education and debugging. There is real value in being able to point at one JSON artifact and say: this is the workflow. Not the handler, not the service, not the notebook. The workflow.
The Contribution Surface#
The roadmap is now clearer than it was when I wrote the earlier contribution note.
The useful next layer is persistence: snapshot serialization, actor tree shape, and enough restore semantics to make machine state portable across process boundaries. That still would not make the library Temporal. It would make it easier to pair the statechart formalism with an external durable runtime.
The second layer is richer XState v5 compatibility: more setup helpers, composable guard helpers, tighter handler signatures, and sharper typed context/event ergonomics. Python cannot copy TypeScript's type system, but it can offer good Protocol, dataclass, and static-checker surfaces.
The third layer is examples. The existing traffic-intersection and fetch-with-retry examples are exactly the right kind because they show why the library exists. More examples should follow that standard: a chart that would be annoying as flags, deterministic tests, and a clear split between declarative structure and live Python implementations.
That is how an alpha project becomes boring enough to depend on.
Close#
The best version of xstate-python is not a Python costume for a TypeScript library.
It is a bridge: Stately and XState for modeling, SCXML-style semantics for execution, Python handlers for real behavior, actors for side effects, clocks for deterministic tests, and explicit caveats where durability or persistence still belongs somewhere else.
That bridge is worth building because workflow bugs are usually not clever. They are boring mismatches between what the team thought the lifecycle was and what the code actually allowed. A shared statechart contract gives the team something better to argue over.