Troubleshooting
Common errors, their causes, and how to fix them.
This page covers common errors you may encounter when using XState-StateMachine, their causes, and how to fix them. It also includes CLI troubleshooting, debugging tips, and common pitfalls.
🧨 Exception Hierarchy
XState-StateMachine provides a clean exception hierarchy so you can catch errors at the right level of specificity:
flowchart TB
root["XStateMachineError<br/><small>base class for every library error</small>"]
root --> build & run & snap
subgraph build["🏗️ build time"]
direction TB
InvalidConfigError --- RootTargetError --- StateNotFoundError --- ImplementationMissingError --- NotSupportedError
NotSupportedError --- MissingExtraError
end
subgraph run["⚡ runtime"]
direction TB
UnhandledEventError --- UnknownEventError --- InvalidEventPayloadError --- InvalidEventError
InvalidEventError --- TransitionFailedError --- RunawayChainError --- ActorSpawningError
ActorSpawningError --- UnresolvedTargetError --- WrongThreadError --- QueueOverflowError --- InterpreterStoppedError
InterpreterStoppedError --- ReentrantWaitError --- TransitionNotAllowedError
end
subgraph snap["đź’ľ snapshots"]
direction TB
SnapshotVersionError --- SnapshotDriftError --- SnapshotCorruptError
SnapshotCorruptError --- SnapshotMidStepError --- SnapshotSerializationError --- RestoredError
RestoredError --- MachineVersionMismatchError --- NoMigrationPathError
NoMigrationPathError --- StoreError --- ConflictError --- StoreUnavailableError
StoreUnavailableError --- LockTimeoutError --- SnapshotTooLargeError --- InvalidKeyError
end
linkStyle default stroke-width:0px
linkStyle 0,1,2 stroke-width:1.5px
| Exception | Raised when |
|---|---|
InvalidConfigError |
Machine configuration is structurally invalid — including a config dict that contains itself (aliased cycle), which used to escape as RecursionError (0.9.0) |
RootTargetError |
A transition targets the machine root, which would empty the configuration; subclass of InvalidConfigError (0.9.0) |
StateNotFoundError |
Target state ID doesn’t exist |
ImplementationMissingError |
Action/guard/service function not provided |
NotSupportedError |
Feature not available in current mode (e.g. async action in SyncInterpreter) |
MissingExtraError |
A contrib integration was used without its pip extra installed; the message names the install command. Also an ImportError |
TransitionNotAllowedError |
A strict Pythonic StateMachine refused a declared event that no active state takes right now; the event is never dispatched |
ExplorationLimitError |
shortest_paths found more configurations than max_configs (found carries the partial result) |
ActorSpawningError |
Error creating a child actor machine |
UnresolvedTargetError |
sendTo / forwardTo / sendParent / escalate named no live actor; the event was dropped and the step marked failed (subclass of ActorSpawningError) |
UnhandledEventError |
Event matched no transition and onUnhandled="error" |
UnknownEventError |
strict=True and the event type isn’t declared anywhere |
InvalidEventPayloadError |
Event payload failed its declared event_schemas validator |
InvalidEventError |
send() was given something that is not an event — a non-str type, a dict without "type". Also a TypeError (0.9.0) |
RunawayChainError |
A self-generated event chain exceeded maxIterations; reported on receipt.error / last_error, never raised (0.9.0) |
TransitionFailedError |
Action raised and actionErrorPolicy="fail" |
WrongThreadError |
Interpreter.send() called from a foreign thread |
QueueOverflowError |
send() refused: bounded inbox is full |
InterpreterStoppedError |
send(wait=True) receipt resolved after the interpreter stopped |
ReentrantWaitError |
An action awaited its own send(wait=True) on the action’s own task — a deadlock, refused eagerly (0.9.0) |
SnapshotVersionError |
Snapshot’s version is newer than this library supports |
SnapshotDriftError |
Snapshot doesn’t belong to the machine restoring it |
SnapshotMidStepError |
get_persisted_snapshot() called while a macrostep is in flight — e.g. from inside an action (0.9.0) |
SnapshotCorruptError |
Snapshot is structurally unusable: missing key, non-object context, unknown status, running with an empty configuration (0.9.0) |
SnapshotSerializationError |
A pending event carries non-JSON-native data (Decimal, datetime) at get_snapshot() time (0.9.0) |
MachineVersionMismatchError |
Restoring a snapshot written under a different machine version label with no migration; subclass of SnapshotDriftError (0.11.0) |
NoMigrationPathError |
SnapshotMigrator has no chain of steps from the snapshot’s version to the machine’s (0.11.0) |
StoreError |
Base of the snapshot-store errors below |
ConflictError |
Optimistic-locking conflict: save(expected_version=n) found a different version in the store; nothing was written |
StoreUnavailableError |
The store backend could not be reached; retryable, nothing was written |
LockTimeoutError |
store.lock(key, timeout=...) could not acquire the lock in time; retryable |
SnapshotTooLargeError |
A snapshot exceeds the store’s max_snapshot_bytes (refused on save and on load) |
InvalidKeyError |
A store key is empty, too long or otherwise unusable; also a ValueError |
RestoredError |
Wraps an error message recovered from a persisted snapshot |
RestoredChainError |
The restored last_chain_error (0.9.1): a RestoredError and a RunawayChainError, so a live-machine isinstance guard survives a restart |
XStateMachineError |
The common base of every exception above — except XStateMachineError catches anything the library raises |
Importing Exceptions
from xstate_statemachine import (
XStateMachineError, # Catch-all for any library error
InvalidConfigError, # Bad machine config
StateNotFoundError, # Bad transition target
ImplementationMissingError,# Missing action/guard/service
ActorSpawningError, # Actor creation failed
UnresolvedTargetError, # Addressed event reached no live actor
NotSupportedError, # Feature not supported in current mode
UnhandledEventError, # Unhandled event, onUnhandled="error"
UnknownEventError, # strict=True and event type is undeclared
InvalidEventPayloadError, # Event payload failed its event_schemas validator
TransitionFailedError, # Action raised, actionErrorPolicy="fail"
WrongThreadError, # send() called off the owning event loop's thread
ReentrantWaitError, # awaited own send(wait=True) from inside an action
QueueOverflowError, # send() refused by a full bounded inbox
InterpreterStoppedError, # send(wait=True) receipt after the interpreter stopped
SnapshotVersionError, # Snapshot version newer than SNAPSHOT_VERSION
SnapshotDriftError, # Snapshot machine_id/machine_hash mismatch
RestoredError, # Error message recovered from a persisted snapshot
)
Catching Errors in Production
from xstate_statemachine import (
create_machine, SyncInterpreter, MachineLogic,
XStateMachineError, InvalidConfigError, ImplementationMissingError
)
def safe_run_machine(config, logic=None):
"""Run a machine with proper error handling."""
try:
machine = create_machine(config, logic=logic)
interp = SyncInterpreter(machine).start()
return interp
except InvalidConfigError as e:
print(f"Config error: {e}")
# Fix: check your JSON structure has 'id' and 'states'
return None
except ImplementationMissingError as e:
print(f"Missing implementation: {e}")
# Fix: provide the missing action/guard/service
return None
except XStateMachineError as e:
print(f"State machine error: {e}")
return None
StateNotFoundError Details
StateNotFoundError includes extra attributes for debugging:
from xstate_statemachine import StateNotFoundError
try:
interp.send("GO_TO_NONEXISTENT")
except StateNotFoundError as e:
print(e.target) # 'nonexistentState' — the state that wasn't found
print(e.reference_id) # 'myMachine.currentState' — where it was referenced from
Common Errors
InvalidConfigError — Missing id
What it looks like:
xstate_statemachine.exceptions.InvalidConfigError: Invalid config: must be a dict with 'id' and 'states' keys.
Why it happens: Your JSON config is missing the required "id" field at the root level.
How to fix it:
{
"id": "myMachine",
"initial": "idle",
"states": {
"idle": {}
}
}
Every machine config must have an "id" string and a "states" object.
InvalidConfigError — Missing states
What it looks like:
xstate_statemachine.exceptions.InvalidConfigError: Invalid config: must be a dict with 'id' and 'states' keys.
Why it happens: The config has an "id" but no "states" object, or "states" is empty.
How to fix it:
config = {
"id": "myMachine",
"initial": "idle",
"states": {
"idle": {
"on": {
"START": "running",
},
},
"running": {},
},
}
machine = create_machine(config)
InvalidConfigError — No Initial State
What it looks like:
xstate_statemachine.exceptions.InvalidConfigError: No initial state defined. Exactly one state must have initial=True
Why it happens: When using the Pythonic API, none of the State objects has initial=True.
How to fix it:
from xstate_statemachine import State, build_machine
idle = State("idle", initial=True) # Mark exactly one state as initial
running = State("running")
machine = build_machine(id="myMachine", states=[idle, running])
For JSON configs, ensure the root-level "initial" key is present:
{
"id": "myMachine",
"initial": "idle",
"states": {
"idle": {},
"running": {}
}
}
StateNotFoundError
What it looks like:
xstate_statemachine.exceptions.StateNotFoundError: Could not find state with ID 'nonExistent'.
Or with context:
xstate_statemachine.exceptions.StateNotFoundError: Could not resolve target state 'runing' from state 'idle'.
Why it happens: A transition target references a state name that doesn’t exist. This is usually a typo.
Note: As of 0.8.0,
create_machine()walks the finished tree and rejects unresolvable targets at build time by default (strict_targets=True), raisingInvalidConfigError— seeInvalidConfigError— Unresolvable transition target below.StateNotFoundErrorstill fires for a target that only becomes invalid at runtime, e.g. one restored from an outdated snapshot, or whenstrict_targets=Falseand the runtime resolver’s fuzzy fallback still fails.
How to fix it: Check the spelling of your target state names:
# Wrong - typo in target
config = {
"id": "test",
"initial": "idle",
"states": {
"idle": {
"on": {
"GO": "runing",
},
}, # Typo!
"running": {},
},
}
# Correct
config = {
"id": "test",
"initial": "idle",
"states": {
"idle": {
"on": {
"GO": "running",
},
}, # Fixed
"running": {},
},
}
InvalidConfigError — Unresolvable transition target
What it looks like:
xstate_statemachine.exceptions.InvalidConfigError: Machine 'test' has unresolvable transition targets:
test.idle: on 'GO' -> target 'runing' does not resolve
Why it happens: create_machine() validates every transition target — on, always, after, onDone, and every invoke’s onDone/onError — against the fully built tree, and reports every unresolved one together in a single message.
How to fix it: Fix the typo, or if you need to keep loading a config with a known-bad target while you migrate it, downgrade the failure to a DeprecationWarning:
machine = create_machine(config, strict_targets=False)
This escape hatch is removed in 1.0 — the unresolved transitions remain silent no-ops at runtime until then.
InvalidConfigError — always self-target can never make progress
What it looks like:
xstate_statemachine.exceptions.InvalidConfigError: Machine 'test' has non-progressing 'always' transitions:
test.idle: always self-target can never make progress -- the transition does not re-enter the state, so 'entry' will not re-run. Add "reenter": true, route via an intermediate state, or give the transition actions that mutate context.
Why it happens: An always transition that targets its own owning state, without "reenter": true and without actions, never exits/re-enters — entry never re-runs, nothing mutates context, and the machine parks forever while reporting "running".
How to fix it: Add "reenter": true, route through an intermediate state, or give the transition actions that mutate context so a guard can eventually flip.
InvalidConfigError — built-in action missing params
What it looks like:
xstate_statemachine.exceptions.InvalidConfigError: Built-in action 'sendTo' is missing required param(s) ['event', 'to']. Found ['event', 'to'] at the top level of the action -- built-in action parameters must be nested under 'params'.
Why it happens: Built-in actions (raise, sendTo, cancel, stopChild, …) require their parameters nested under "params". Placing them at the top level of the action dict — a natural mistake — leaves the required key missing.
How to fix it:
{
"type": "sendTo",
"params": {
"to": "someActor",
"event": {
"type": "PING"
}
}
}
not
{
"type": "sendTo",
"to": "someActor",
"event": {
"type": "PING"
}
}
InvalidConfigError — invoke.src must be a service name
What it looks like:
xstate_statemachine.exceptions.InvalidConfigError: State 'm.a' invoke 'kid': 'src' must be a service name (str), got dict. To invoke a nested machine, build it with create_machine(...) and register it under that name in MachineLogic(services={...}).
Why it happens: src names a service; the value is looked up in MachineLogic.services (or discovered). XState’s JS API accepts an inline machine object there — this library does not, and before 0.9.0 the attempt died as a bare TypeError: unhashable type: 'dict' from deep inside the logic loader (#231).
How to fix it:
child = create_machine(
{
"id": "kid",
"initial": "k",
"states": {
"k": {},
},
}
)
logic = MachineLogic(
services={
"kid": child,
}
)
# and in the config: "invoke": {"id": "kid", "src": "kid"}
InvalidConfigError / WARNING — unknown config key
What it looks like:
WARNING ⚠️ Machine 'm' has unknown config key(s) -- m.a: 'entyr' (did you mean 'entry'?), 'onn' (did you mean 'on'?). Unknown keys are ignored by the parser, so a misspelled key silently does nothing ...
Why it happens: the parser reads a fixed set of keys at each level (root, state, transition, invoke) and ignores the rest — a misspelled entry is an entry action that never runs, a misspelled on a transition that does not exist. Since 0.9.0 every unknown key at every level is reported with its path and the closest known key (#216, #220).
How to fix it: correct the key. To make the build refuse instead of warn, pass create_machine(..., strict_config=True) or set "strictConfig": true in the config. Custom metadata belongs under meta, or under a key with the reserved x- prefix, which is never reported.
RunawayChainError
What it looks like:
receipt.error -> RunawayChainError: Machine 'spin' exceeded 6 chained self-generated events in one macrostep and discarded 7 of them. ...
Why it happens: an action raises (or sends to its own machine) the event that triggers it again with no delay, an invoke.onDone ping-pongs between two states, or two always transitions target each other. maxIterations (default 1000) bounds that self-fed work within a step; the tail is discarded and the machine stays running. A delayed self-send (raise with delay, or after) is a timer and is never counted (#212).
How to observe it: the trip is sticky — interpreter.chain_trips (monotonic, persisted), interpreter.last_chain_error (latched until clear_chain_error(), persisted), and on_chain_budget_exceeded once per trip. last_error / receipt.error carry it too, but last_error is a per-step read that the next clean event erases. If the cut left a state whose service will never complete, on_invocation_stranded fires and has_dormant_invocations is True.
ReentrantWaitError
What it looks like:
xstate_statemachine.exceptions.ReentrantWaitError: Action on 'm' awaited send('GO', wait=True) on its own interpreter. The receipt resolves only when the run loop processes the event, and the loop cannot advance until this action returns -- that is a deadlock. ...
Why it happens: await i.send("GO", wait=True) inside an action, on the action’s own task. Before 0.9.0 this hung silently with status == "running".
How to fix it: send without wait (the event runs right after the current step), or hand the receipt out — asyncio.ensure_future(i.send("GO", wait=True)) — and await it from another task. A helper task the action spawns may await the machine freely; the rule is about the action’s own task (#225). A plain def action that calls send(wait=True) and drops the result gets a RuntimeWarning instead (#232). The SyncInterpreter raises the same error for a send(wait=True) from inside an action.
WrongThreadError
What it looks like:
xstate_statemachine.exceptions.WrongThreadError: Interpreter 'm' is bound to the event loop on thread 'MainThread'; send() was called from thread 'Thread-1'. send() must run on the interpreter's own loop thread. From another thread use send_threadsafe(). Note that asyncio.run_coroutine_threadsafe(interp.send(...), loop) is also rejected since 0.8.0, because this check runs before the coroutine is scheduled; replace it with send_threadsafe().
Why it happens: The async Interpreter.send() is bound to the event loop that started it. Calling it from a different thread cannot be awaited there, and before 0.8.0 the event was silently lost.
How to fix it: Use send_threadsafe() from a foreign thread:
interp.send_threadsafe("TICK")
SyncInterpreter has no owning event loop and is unaffected.
QueueOverflowError
What it looks like:
xstate_statemachine.exceptions.QueueOverflowError: Interpreter 'm' inbox is full (1/1); event refused. Shed load, slow the producer, or raise max_queue_size.
Why it happens: The async Interpreter was constructed with max_queue_size and the default OverflowPolicy.RAISE, and a send() arrived while the inbox already held max_queue_size unprocessed events. It carries interpreter_id, depth, and maxsize attributes for logging.
How to fix it: Shed load, slow the producer, or raise the bound:
import asyncio
from xstate_statemachine import (
create_machine,
Interpreter,
QueueOverflowError,
OverflowPolicy,
)
async def main():
config = {
"id": "m",
"initial": "a",
"states": {
"a": {
"on": {
"GO": "a",
},
},
},
}
machine = create_machine(config)
interp = Interpreter(
machine, max_queue_size=1, overflow_policy=OverflowPolicy.RAISE
)
await interp.start()
try:
for _ in range(5):
interp.send("GO")
except QueueOverflowError as e:
print(f"Dropped event: {e}") # e.interpreter_id, e.depth, e.maxsize
await interp.stop()
asyncio.run(main())
InterpreterStoppedError
What it looks like:
An exception on the Future/awaitable returned by send(..., wait=True), raised because the interpreter stopped (or dropped the event) before it was processed.
Why it happens: You awaited the receipt of a send(wait=True) call, but the interpreter was stopped — or the event was otherwise discarded — before it reached the front of the queue, so there is no result to resolve the receipt with.
How to fix it: Catch it around the awaited receipt, and make sure you aren’t racing a stop() against in-flight sends:
import asyncio
from xstate_statemachine import (
create_machine,
Interpreter,
InterpreterStoppedError,
)
async def main():
config = {
"id": "m",
"initial": "a",
"states": {
"a": {
"on": {
"GO": "b",
},
},
"b": {},
},
}
machine = create_machine(config)
interp = Interpreter(machine)
await interp.start()
receipt = interp.send("GO", wait=True)
await interp.stop() # stops before the receipt is necessarily resolved
try:
await receipt
except InterpreterStoppedError as e:
print(f"Send never completed: {e}")
asyncio.run(main())
UnknownEventError
What it looks like:
xstate_statemachine.exceptions.UnknownEventError: Event 'GOO' is not declared by machine 'm'. Known events: GO. Did you mean 'GO'?
Why it happens: With strict=True (via the constructor or the machine config’s "strict" key), send() rejects any event type that isn’t declared anywhere in the machine — a typo or a stale producer. This is distinct from an event that is declared but not handled by the current state, which stays a normal, silent no-op. UnknownEventError carries event_type, machine_id, and the sorted known event types, plus a difflib-based “did you mean” suggestion.
How to fix it: Fix the typo, or add the event to the machine’s on handlers if it’s genuinely new:
from xstate_statemachine import (
create_machine,
SyncInterpreter,
UnknownEventError,
)
config = {
"id": "m",
"initial": "a",
"states": {
"a": {
"on": {
"GO": "a",
},
},
},
}
machine = create_machine(config)
interp = SyncInterpreter(machine, strict=True).start()
try:
interp.send("GOO") # Typo!
except UnknownEventError as e:
print(e) # ...Did you mean 'GO'?
InvalidEventPayloadError
What it looks like:
xstate_statemachine.exceptions.InvalidEventPayloadError: payload for 'GO' failed validation: amount must be int
Why it happens: create_machine() was given event_schemas, a mapping of event type to a validator callable. When send() delivers an event whose type has a registered schema, the validator runs against the payload; if it raises, that exception is captured as cause and re-raised as InvalidEventPayloadError.
How to fix it: Fix the payload at the call site, or relax/correct the validator:
from xstate_statemachine import (
create_machine,
SyncInterpreter,
InvalidEventPayloadError,
)
def validate_go(payload):
if not isinstance(payload.get("amount"), int):
raise ValueError("amount must be int")
config = {
"id": "m",
"initial": "a",
"states": {
"a": {
"on": {
"GO": "a",
},
},
},
}
machine = create_machine(
config,
event_schemas={
"GO": validate_go,
},
)
interp = SyncInterpreter(machine).start()
try:
interp.send(
{
"type": "GO",
"amount": "oops",
}
)
except InvalidEventPayloadError as e:
print(e) # payload for 'GO' failed validation: amount must be int
print(e.cause) # the original ValueError
SnapshotVersionError
What it looks like:
xstate_statemachine.exceptions.SnapshotVersionError: Snapshot version 999 is newer than the supported version 3. Upgrade xstate-statemachine to restore it.
Why it happens: The snapshot’s version field (the payload layout version) is higher than this installed library’s SNAPSHOT_VERSION. It was written by a newer release and cannot be read safely, so the restore is refused rather than half-applied.
How to fix it: Upgrade xstate-statemachine to a version that supports that snapshot layout, or restore the snapshot with the library version that produced it.
SnapshotDriftError
What it looks like:
xstate_statemachine.exceptions.SnapshotDriftError: machine 'm' structure changed since this snapshot was taken (deadbeefdeadbeef != f21b173044383a6d). Migrate the snapshot, or pass verify_machine_hash=False if the change is known to be compatible.
or, for an id mismatch:
xstate_statemachine.exceptions.SnapshotDriftError: snapshot was taken from machine 'other' but is being restored into 'm'
Why it happens: from_snapshot() refuses to restore a snapshot into a machine it doesn’t recognize — either the machine_id differs, or (when verify_machine_hash=True, the default) the machine’s structure_hash has changed since the snapshot was taken (a guard added, a state renamed, a transition retargeted).
How to fix it: If the change is known to be backward-compatible, migrate the persisted context/state as needed and restore with verify_machine_hash=False:
restored = SyncInterpreter.from_snapshot(
migrated_json, machine, verify_machine_hash=False
)
See Snapshots — Snapshot Envelope for the full migration pattern.
TransitionFailedError
What it looks like:
interp.status == "stopped"
interp.error -> TransitionFailedError: Action 'charge' raised during a transition from 'm.a'; the transition was rolled back and the machine stopped (actionErrorPolicy='fail').
Why it happens: an action raised while actionErrorPolicy is "fail". The transition is rolled back (configuration and context restored to the checkpoint), then the machine stops — status == "stopped", configuration cleared — and the wrapping exception is on interp.error. Not a raised exception on send(); "error" remains the status for an invoked service that died.
How to fix it: read interp.error (and on_transition_failed / on_action_error, which fire first). Use "rollback" if you want the undo without the stop.
UnhandledEventError
What it looks like:
interp.status == "error"
interp.error -> UnhandledEventError: Event 'BANANA' is not handled in any active state ['m.idle'] and the machine's onUnhandled policy is 'error'.
Why it happens: the machine has "onUnhandled": "error" and an event selected no transition in any active state. send() does not raise; the machine moves to the terminal "error" status with the exception on interp.error, and on_error fires. Under the default "ignore" the same event is a silent no-op (XState semantics); under "defer" it is parked and replayed after the next configuration change. Engine-minted events are exempt by provenance.
How to fix it: either handle the event in the state, or accept that it is legitimately unhandled here and use "ignore" / "defer". on_unhandled_event fires under every policy with the disposition; a send(wait=True) receipt carries the error.
RootTargetError
What it looks like:
xstate_statemachine.exceptions.RootTargetError: Machine 'm' has transition(s) targeting the machine root:
m.a: on 'RESET' -> target '#m' is the machine root; entering it empties the configuration. ...
Why it happens: a transition’s target names the machine itself ("#m" or the root id). Entering the root enters nothing, so the machine would have no active leaf. A subclass of InvalidConfigError, raised at create_machine() (0.9.0, #108).
How to fix it: target the root’s initial state (or any concrete state) instead.
RestoredError
What it looks like:
An interpreter restored via from_snapshot() sits in the "error" status, and interp.error is a RestoredError instance rather than the original exception.
Why it happens: When a snapshot is taken from a machine that had stopped with an error, the original exception object can’t survive JSON serialization. from_snapshot() wraps the recorded error message in RestoredError so the restored interpreter still exposes what went wrong, instead of leaving error as None.
How to fix it: Treat RestoredError as a message-only diagnostic — check interp.status after restoring, and read str(interp.error) for the original failure text; don’t rely on isinstance checks against the original exception type:
from xstate_statemachine import SyncInterpreter, RestoredError
restored = SyncInterpreter.from_snapshot(persisted_json, machine)
if restored.status == "error":
assert isinstance(restored.error, RestoredError)
print(f"Restored in error state: {restored.error}")
SnapshotMidStepError
What it looks like:
xstate_statemachine.exceptions.SnapshotMidStepError: Interpreter 'order' is mid-macrostep: a transition's actions are still running and the configuration has no leaf. Snapshot it once the step settles (await send(..., wait=True), or from on_transition).
Why it happens: Between a transition’s exit set and its entry set the machine has no leaf state. A snapshot taken there — typically from inside an action — used to persist state_ids: [] and restore as a permanently inert machine that still reported running. Since 0.9.0 the call is refused instead.
How to fix it: Move the snapshot out of the action. Snapshot from an on_transition plugin hook, after await interp.send(..., wait=True) returns, or after stop(drain=True). Child actors caught mid-step by a parent’s snapshot are waited for, so this only fires for the interpreter you call it on.
RestoredChainError
What it looks like:
restored = SyncInterpreter.from_snapshot(blob, machine)
type(restored.last_chain_error) # RestoredChainError
isinstance(restored.last_chain_error, RunawayChainError) # True
restored.last_chain_error.limit # None -- JSON kept the message only
Why it happens: the chain-trip latch (last_chain_error, #222) is a snapshot field (#226). JSON cannot carry a Python type, so it comes back wrapped — but as a RestoredChainError, which subclasses both RestoredError and RunawayChainError (0.9.1, #243), so a supervisor’s isinstance guard keeps firing across a restart. The type-independent signal is chain_trips > 0.
How to fix it: nothing to fix; call clear_chain_error() to acknowledge the trip once you have acted on it.
SnapshotCorruptError / SnapshotSerializationError
What they look like:
xstate_statemachine.exceptions.SnapshotCorruptError: Snapshot is malformed: missing required key 'context'.
xstate_statemachine.exceptions.SnapshotSerializationError: Pending event 'done.invoke.pay' carries data that is not JSON-serialisable (Object of type Decimal is not JSON serializable). Snapshots refuse to coerce values silently; make the data JSON-native or snapshot from a quiesced interpreter.
Why they happen: from_snapshot() validates the blob’s shape before touching the machine (0.9.0) — a truncated or hand-edited snapshot is refused with a typed error rather than a KeyError deep inside restore. get_snapshot() refuses to write a pending event whose data JSON cannot represent faithfully; before 0.9.0 a Decimal silently became a str and the restored handler received the wrong type.
How to fix it: For SnapshotCorruptError, treat the blob as lost and rebuild from your source of truth. For SnapshotSerializationError, return JSON-native values from services (convert Decimal → str/float, datetime → ISO string) or snapshot after the pending event has been processed.
InvalidEventError
What it looks like:
xstate_statemachine.exceptions.InvalidEventError: Unsupported event type passed to send(): int. Pass a str, a dict with a 'type' key, or an Event.
Why it happens: send(123) or send({"kind": "X"}). Before 0.9.0 the malformed value could travel into the hierarchy before failing. The error is also a TypeError, so code that caught TypeError keeps working.
ImplementationMissingError
What it looks like:
xstate_statemachine.exceptions.ImplementationMissingError: Guard 'userIsAdmin' not implemented.
Why it happens: Your JSON config references an action, guard, or service by name, but no Python function with a matching name was provided in MachineLogic, logic_providers, or logic_modules.
How to fix it:
from xstate_statemachine import create_machine, SyncInterpreter, MachineLogic
config = {
"id": "test",
"initial": "s1",
"states": {
"s1": {
"on": {
"EVENT": {
"target": "s2",
"guard": "userIsAdmin",
},
},
},
"s2": {},
},
}
# Provide the missing guard implementation
def user_is_admin(context, event):
return context.get("role") == "admin"
logic = MachineLogic(
guards={
"user_is_admin": user_is_admin,
}
)
machine = create_machine(config, logic=logic)
Tip: The
LogicLoadermatchessnake_casePython function names tocamelCaseJSON names automatically. Souser_is_adminmatches"userIsAdmin".
NotSupportedError — Async Guard
What it looks like:
xstate_statemachine.exceptions.NotSupportedError: Guard 'my_guard' must be synchronous (guards cannot be async)
Why it happens: You decorated a guard function with @guard and async def. Guards must be synchronous because they need to return a boolean immediately to decide whether a transition should proceed. (Only the decorator raises; an async callable registered through MachineLogic(guards=...) is not detected and would evaluate as a truthy coroutine, so never do that.)
How to fix it:
from xstate_statemachine import guard
# Wrong - guards cannot be async
@guard
async def my_guard(context, event): # This will raise NotSupportedError
return True
# Correct - guards must be sync
@guard
def my_guard(context, event):
return context.get("count", 0) > 0
NotSupportedError — Async Action with SyncInterpreter
What it looks like:
xstate_statemachine.exceptions.NotSupportedError: Async action 'async_action' not supported by SyncInterpreter.
Why it happens: SyncInterpreter does support after (delayed) transitions as of 0.8.0 — it doesn’t need an async event loop for those. What it can’t do is run an async def action, guard, or service, since there’s no event loop available to await the coroutine.
How to fix it: Use a synchronous function for actions run under SyncInterpreter, or switch to the async Interpreter if you need async actions/services:
from xstate_statemachine import (
create_machine,
SyncInterpreter,
MachineLogic,
NotSupportedError,
)
async def async_action(interpreter, context, event, action_def): ...
config = {
"id": "m",
"initial": "a",
"states": {
"a": {
"entry": "async_action",
},
},
}
machine = create_machine(
config,
logic=MachineLogic(
actions={
"async_action": async_action,
}
),
)
try:
SyncInterpreter(machine).start() # Raises NotSupportedError
except NotSupportedError as e:
print(e)
after (Delayed) Transitions with SyncInterpreter
SyncInterpreter fully supports after transitions. It has no background event loop, so timers are tracked on a clock (defaulting to RealClock) and only fire when something pumps the clock — either your own call to interp.tick(), or the next send()/start(), which pumps as a side effect.
Using the real clock, sleep past the deadline and call tick() to fire it:
import time
from xstate_statemachine import create_machine, SyncInterpreter
config = {
"id": "timer",
"initial": "idle",
"states": {
"idle": {
"after": {
"200": "timeout",
},
},
"timeout": {},
},
}
machine = create_machine(config)
interp = SyncInterpreter(machine).start()
print(interp.current_state_ids) # {'timer.idle'}
time.sleep(0.3)
interp.tick() # Pumps the clock and fires due timers
print(interp.current_state_ids) # {'timer.timeout'}
Using SimulatedClock for deterministic tests — advance virtual time instead of sleeping:
from xstate_statemachine import create_machine, SyncInterpreter
from xstate_statemachine.clock import SimulatedClock
machine = create_machine(config)
clock = SimulatedClock()
interp = SyncInterpreter(machine, clock=clock).start()
print(interp.current_state_ids) # {'timer.idle'}
clock.increment(200) # Advances virtual time and fires due timers
print(interp.current_state_ids) # {'timer.timeout'}
See Testing & The Pure API for more on SimulatedClock.
ActorSpawningError
What it looks like:
xstate_statemachine.exceptions.ActorSpawningError: Failed to spawn actor: ...
Why it happens: An invoke configuration references a service that should return a MachineNode, but the service returned something else (or failed to return).
How to fix it: Ensure your service function returns a valid MachineNode:
from xstate_statemachine import create_machine
def my_actor_service(interpreter, context, event):
child_config = {
"id": "child",
"initial": "active",
"states": {
"active": {},
},
}
return create_machine(child_config) # Must return a MachineNode
CLI Troubleshooting
| Issue | Cause | Fix |
|---|---|---|
xsm: command not found |
Package not installed, or entry point not on PATH | Run pip install xstate-statemachine or use python -m xstate_statemachine |
Program 'xsm.exe' failed to run: An Application Control policy has blocked this file (Windows) |
WDAC / AppLocker / Smart App Control refuses pip’s unsigned xsm.exe launcher; python.exe itself is allowed |
Run python -m xstate_statemachine setup once (parks the launcher, installs an xsm.cmd shim; --undo reverts). python -m xstate_statemachine … always works. Details in CLI Tool → Windows |
| Files not generated | Output directory doesn’t exist, or files already exist | Use -o ./output/ with an existing directory; use --force to overwrite |
| Wrong template used | Using deprecated --style flag |
Use --template pythonic-class instead of --style class |
| Encoding errors on Windows | Console doesn’t support UTF-8 emoji characters | Set PYTHONIOENCODING=utf-8 or use chcp 65001 in cmd |
--json-parent specified twice |
Validation error | Only one --json-parent is allowed; use --json-child for additional machines |
| Generated code has async but I want sync | Default async mode varies by template | Add --async-mode no to your command |
JSONDecodeError during generation |
Invalid JSON syntax in input file | Validate your JSON file with python -m json.tool my_machine.json |
Debugging Tips
1. Attach LoggingInspector
The LoggingInspector plugin logs every transition, action, guard, and state change:
from xstate_statemachine import create_machine, SyncInterpreter, LoggingInspector
machine = create_machine(config, logic=logic)
interp = SyncInterpreter(machine)
interp.use(LoggingInspector()) # Attach the inspector
interp.start()
interp.send("MY_EVENT")
# Console output will show the full transition trace
2. Check Active States After Each Event
interp.start()
print("After start:", interp.current_state_ids)
interp.send("SUBMIT")
print("After SUBMIT:", interp.current_state_ids)
interp.send("CONFIRM")
print("After CONFIRM:", interp.current_state_ids)
This helps you see exactly which state the machine is in after each event.
3. Inspect Context
interp.start()
print("Initial context:", interp.context)
interp.send("ADD_ITEM")
print("After ADD_ITEM:", interp.context)
Context is shared across all states — verify that your actions are modifying it correctly.
4. Export Diagrams to Visualize
# Generate a Mermaid diagram of your machine
print(machine.to_mermaid())
Paste the output into mermaid.live to see a visual representation of your state machine.
5. Use SyncInterpreter for Deterministic Debugging
When debugging, prefer SyncInterpreter over Interpreter because:
- Events are processed immediately (no event loop timing issues)
- State changes happen synchronously
- Easier to inspect state after each operation
from xstate_statemachine import create_machine, SyncInterpreter
machine = create_machine(config)
interp = SyncInterpreter(machine)
interp.start()
# Everything is synchronous — easy to step through
interp.send("EVENT_A")
assert "myMachine.stateB" in interp.current_state_ids
Common Pitfalls
Guards Must Return bool
Guards must return True or False. If a guard returns a non-boolean truthy/falsy value, it may work but leads to confusing behavior. Always be explicit:
# Bad - returns an int
def has_items(context, event):
return len(context["items"]) # Returns 0 or N, not True/False
# Good - returns a bool
def has_items(context, event):
return len(context["items"]) > 0
Guards Must Be Synchronous
Guards cannot be async def. The @guard decorator enforces this:
# This will raise NotSupportedError
@guard
async def check_something(context, event):
return True
# Use sync instead
@guard
def check_something(context, event):
return True
Action Signature Has 4 Parameters, Guard Has 2
Action functions receive (interpreter, context, event, action_def) — four parameters. Guard functions receive (context, event) — two parameters. Mixing them up causes TypeError:
# Action: 4 params (5 with self in a class)
def my_action(interpreter, context, event, action_def):
context["count"] += 1
# Guard: 2 params (3 with self in a class)
def my_guard(context, event):
return context["count"] > 0
# Service: 3 params (4 with self in a class)
def my_service(interpreter, context, event):
return {
"result": "done",
}
Context Is Shared (Not Per-State)
The context dict is a single object shared across all states. Any action in any state can read and modify it:
config = {
"id": "test",
"initial": "a",
"states": {
"a": {
"entry": "setFlagA",
"on": {
"GO": "b",
},
},
"b": {
"entry": "readFlagA", # Can access context["flagA"] set by state "a"
},
},
}
Event Names Are Case-Sensitive
"SUBMIT", "submit", and "Submit" are three different events:
# This will NOT trigger the transition
interp.send("submit") # Wrong case!
# This will trigger it
interp.send("SUBMIT") # Correct
Tip: By convention, XState uses UPPER_SNAKE_CASE for event names (e.g.,
SUBMIT,ADD_ITEM,PAYMENT_DONE).
FAQ-Style Troubleshooting
“My transition isn’t firing”
Check these in order:
- Event name case: Event names are case-sensitive.
"SUBMIT"is not"submit". - Guard returning False: If a guard is attached to the transition, it may be returning
False. Add logging to your guard to verify. - Wrong source state: The machine must be in the state where the transition’s
onblock is defined. Checkinterp.current_state_ids. - Final state: If the machine is in a
finalstate, no more transitions can occur.
# Debug: Print current state before sending event
print(f"Current state: {interp.current_state_ids}")
interp.send("MY_EVENT")
print(f"After event: {interp.current_state_ids}")
“My action isn’t running”
- Name mismatch: The function name in Python must match the action name in JSON. With
LogicLoader,snake_caseauto-maps tocamelCase(e.g.,calculate_total→calculateTotal). - Not registered: If using
MachineLogic, make sure the action is in theactionsdict. If usinglogic_providers, make sure the method exists on the class. - Transition didn’t happen: The action only runs if the transition actually fires. Check that the transition isn’t blocked by a guard.
# Verify your action is discoverable
logic = MachineLogic(
actions={
"calculateTotal": my_action_fn,
}
)
machine = create_machine(config, logic=logic)
“My service result isn’t being used”
Services invoked via invoke produce onDone events when they return. Make sure:
- The service returns a dict:
return {"result": "done"} - The
onDonetransition is defined in the invoke config - You’re using the async
Interpreterif the service is async
“I get TypeError when my function is called”
Your function signature doesn’t match what the interpreter expects:
# Actions: (interpreter, context, event, action_def)
# Guards: (context, event)
# Services: (interpreter, context, event)
# In a class (add self):
# Actions: (self, interpreter, context, event, action_def)
# Guards: (self, context, event)
# Services: (self, interpreter, context, event)
“My generated code imports fail”
Make sure xstate_statemachine is installed in your Python environment:
pip install xstate-statemachine
If using generated files with separate logic/runner, both files must be in the same directory (or on the Python path).