Troubleshooting
Common errors, their causes, and how to fix them.
Troubleshooting
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
end
subgraph run["⥠runtime"]
direction TB
UnhandledEventError --- UnknownEventError --- InvalidEventPayloadError --- InvalidEventError
InvalidEventError --- TransitionFailedError --- RunawayChainError --- ActorSpawningError
ActorSpawningError --- WrongThreadError --- QueueOverflowError --- InterpreterStoppedError
end
subgraph snap["đž snapshots"]
direction TB
SnapshotVersionError --- SnapshotDriftError --- SnapshotCorruptError
SnapshotCorruptError --- SnapshotMidStepError --- SnapshotSerializationError --- RestoredError
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.8.1) |
RootTargetError |
A transition targets the machine root, which would empty the configuration; subclass of InvalidConfigError (0.8.1) |
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) |
ActorSpawningError |
Error creating a child actor machine |
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.8.1) |
RunawayChainError |
A self-generated event chain exceeded maxIterations; reported on receipt.error / last_error, never raised (0.8.1) |
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 |
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.8.1) |
SnapshotCorruptError |
Snapshot is structurally unusable: missing key, non-object context, unknown status, running with an empty configuration (0.8.1) |
SnapshotSerializationError |
A pending event carries non-JSON-native data (Decimal, datetime) at get_snapshot() time (0.8.1) |
RestoredError |
Wraps an error message recovered from a persisted snapshot |
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
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
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"} }
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 1. 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.
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.8.1 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.
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.8.1) â 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.8.1 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.8.1 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 defined a guard function with async def. Guards must be synchronous because they need to return a boolean immediately to decide whether a transition should proceed.
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.cli |
| 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. This is enforced by the library:
# 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).