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), raising InvalidConfigError — see InvalidConfigError — Unresolvable transition target below. StateNotFoundError still fires for a target that only becomes invalid at runtime, e.g. one restored from an outdated snapshot, or when strict_targets=False and 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 LogicLoader matches snake_case Python function names to camelCase JSON names automatically. So user_is_admin matches "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:

  1. Event name case: Event names are case-sensitive. "SUBMIT" is not "submit".
  2. Guard returning False: If a guard is attached to the transition, it may be returning False. Add logging to your guard to verify.
  3. Wrong source state: The machine must be in the state where the transition’s on block is defined. Check interp.current_state_ids.
  4. Final state: If the machine is in a final state, 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”

  1. Name mismatch: The function name in Python must match the action name in JSON. With LogicLoader, snake_case auto-maps to camelCase (e.g., calculate_total → calculateTotal).
  2. Not registered: If using MachineLogic, make sure the action is in the actions dict. If using logic_providers, make sure the method exists on the class.
  3. 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:

  1. The service returns a dict: return {"result": "done"}
  2. The onDone transition is defined in the invoke config
  3. You’re using the async Interpreter if 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).