Core Concepts

States, events, transitions, guards, actions — the building blocks of every state machine.

Every state machine is built from a small set of fundamental concepts. This page covers all of them.

🗺️ The Big Picture

stateDiagram-v2
    direction LR
    state "toggle · context: { flips: 0 }" as machine {
        [*] --> off
        off --> on : TOGGLE
        on --> off : TOGGLE
    }

This toggle switch has 2 states (off, on), 1 event type (TOGGLE), 2 transitions (one in each direction), and a context tracking flip count.

📖 Core Vocabulary

Concept What It Is Example
State A distinct mode the system can be in "off", "loading", "error"
Event Something that happens from the outside "TOGGLE", "SUBMIT", "TIMEOUT"
Transition A rule: “when event X in state A, go to state B” off --TOGGLE--> on
Guard A boolean condition that must be true for the transition "isAdult" — only transition if age >= 18
Action A side effect that runs during a transition "logToggle" — print a message when toggling
Context Mutable data the machine carries with it { "retries": 0, "user": null }
Service An async operation invoked when entering a state "fetchUserData" — API call, DB query
Final State A terminal state with no outgoing transitions "success", "completed"

⭐ The Golden Rule

A state machine can only be in ONE state at a time (unless using parallel states). It can ONLY move to another state when it receives an event that matches a defined transition.

This eliminates the “impossible states” problem. No more isLoading && hasError && isAuthenticated contradictions. If you’re in the loading state, you are only loading — not errored, not authenticated, not idle.

# Traditional approach — impossible states are possible
is_loading = True
has_error = True       # Loading AND errored? Bug waiting to happen.
is_authenticated = True

# State machine approach — exactly ONE state at a time
# State is "loading" OR "error" OR "authenticated" — never multiple.

[!NOTE] Error & unhandled-event policies. By default (strict: False, onUnhandled: "ignore") an event with no matching transition is dropped silently — no crash. You can tighten this: set strict: True to raise UnknownEventError for event types the machine never declares, or set onUnhandled to "error"/"defer" to change how known-but-unhandled events are treated. Similar knobs exist for action/guard failures via actionErrorPolicy and guardErrorPolicy. See the JSON Configuration Reference and Troubleshooting.


📨 How Events Work

Events are the only way to trigger state changes. They come from the outside world — user clicks, API responses, timers, or your own code calling send().

# String shorthand — most common
interpreter.send("TOGGLE")

# With payload data
interpreter.send("LOGIN", username="alice", password="secret")

# Event object for full control
from xstate_statemachine import Event

interpreter.send(
    Event(
        type="LOGIN",
        payload={
            "username": "alice",
        },
    )
)

# Dict form
interpreter.send(
    {
        "type": "LOGIN",
        "username": "alice",
    }
)

# Multiple events in sequence
interpreter.send_events(["STEP_1", "STEP_2", "STEP_3"])

Tip: Event names are conventionally UPPER_CASE ("TOGGLE", "SUBMIT", "FETCH"). State names are lowercase ("idle", "loading", "done"). This makes it easy to tell them apart at a glance.

Engine events vs. your events — decided by provenance, not by name. The engine synthesises done.invoke.<id>, error.platform.<id>, after.<ms>.<stateId> (for example after.3000.myApp.pending), xstate.error.actor.<id> and the init/exit sentinels. Everywhere the library has to ask “is this the machine’s own traffic, or a user event it forgot to handle?” — the onUnhandled policy and strict mode — it checks who minted the event, not what it is called. So a user event you name done.review is ordinary user traffic: it trips onUnhandled: "error" if nothing handles it, and strict rejects it unless declared. ("*" and "prefix.*" descriptors match both kinds, as in XState v5.) (Before 0.9.0 this was a prefix test on the name, and such an event silently got system-event semantics — #79.) The engine’s name shapes are still importable as xstate_statemachine.events.ENGINE_EVENT_SHAPES for documentation and build-time checks; there is no longer any reason to avoid the prefixes, though review.done reads better than done.review.


🏷️ Engine events and provenance

Some events are minted by the engine itself: done.invoke.<id> / done.state.<id> (DoneEvent), error.platform.<id> and xstate.error.actor.<id> (ErrorEvent), after.<ms>.<stateId> (AfterEvent), and the init/exit sentinels. Two behaviours turn on the distinction between those and your events:

Behaviour Engine event Your event
onUnhandled: "error" / "defer" exempt applies
strict=True never rejected must be declared

Wildcard descriptors do not distinguish them: as in XState v5, "*" and "prefix.*" match engine events too. A state’s own onDone, onError or after handler still outranks a "*" on the same state, and a "*" on a deeper state outranks an ancestor’s handler. So a service failure caught only by a "*" (or "error.*") handler is consumed there and the machine keeps running. (Before 0.12.0 engine events never reached a wildcard.)

The decision is made by provenance — who created the event — not by its name. is_system_event(event) is the single predicate both consult; system_event(type, **payload) is the only way to mint a plain Event that passes it. Both are exported from the package root. A user event you happen to name done.review is user traffic; the marker cannot be forged from a name, and it survives deepcopy, pickle, send(wait=True) and a snapshot round-trip.

from xstate_statemachine import Event, is_system_event, system_event

is_system_event(Event("done.review"))                     # False — yours
is_system_event(system_event("___xstate_statemachine_init___"))  # True

ENGINE_EVENT_SHAPES lists the name shapes the engine produces, for build-time checks and documentation; it is not what the runtime keys on.

🚨 Error & Unhandled-Event Policies

By default, an event that doesn’t match any transition from the current state is dropped silently, and an action that raises is logged and the transition still completes, and a guard that raises is treated as False. For most apps that’s the right default, but a few config keys let you change this per machine:

  • strict (bool, default False) — when True, sending an event type the machine never declares anywhere in its config raises UnknownEventError instead of being ignored. Useful for catching typos in event names during development.
  • onUnhandled ("ignore" "defer" "error", default "ignore") — controls what happens when a known event has no transition from the current state. "ignore" drops it (the default), "error" raises UnhandledEventError, and "defer" queues it for later instead of discarding it.
  • actionErrorPolicy ("continue" "rollback" "fail", default "continue") and guardErrorPolicy ("false" "true" "raise", default "false") decide what a raising action or guard does: commit, roll back or stop the machine; treat the guard as False or True, or surface the exception. See Reliability.
from xstate_statemachine import create_machine, SyncInterpreter

config = {
    "id": "strictDemo",
    "strict": True,
    "onUnhandled": "error",
    "initial": "idle",
    "states": {
        "idle": {
            "on": {
                "START": "running",
            },
        },
        "running": {},
    },
}

machine = create_machine(config)
interp = SyncInterpreter(machine).start()
interp.send("START")  # idle -> running

interp.send("START")  # known event, no transition from "running"
print(interp.status)  # "error" -- onUnhandled: "error" fails the machine
print(type(interp.error).__name__)  # UnhandledEventError

try:
    machine2 = create_machine(
        {
            **config,
            "onUnhandled": "ignore",
        }
    )
    interp2 = SyncInterpreter(machine2).start()
    interp2.send("NOT_A_REAL_EVENT")  # never declared anywhere in config
except Exception as exc:
    print(type(exc).__name__)  # UnknownEventError -- raised by `strict`

See the JSON Configuration Reference for the full list of config keys, and Troubleshooting for how these errors surface in practice.


➡️ How Transitions Work

A transition answers one question: “When event X happens while in state A, what should happen?”

[!IMPORTANT] The snippets below show the shape of a transition. State.to() returns a Transition — it does not register one. Assign it inside a StateMachine class body (submit = editing.to(...)), or pass it to build_machine(transitions=[...]). A bare off.to(on, ...) statement is discarded, and the machine will start but never move.

Simple String Target

The most basic form — just specify where to go:

# Pythonic
off.to(on, event="TOGGLE")

# JSON equivalent
{
    "on": {
        "TOGGLE": "on",
    },
}

Transition with Actions

Run side effects when the transition fires:

# Pythonic
off.to(on, event="TOGGLE", actions="logToggle")

# JSON equivalent
{
    "on": {
        "TOGGLE": {
            "target": "on",
            "actions": "logToggle",
        },
    },
}

# Multiple actions
off.to(on, event="TOGGLE", actions=["validate", "logToggle"])

Guarded Transitions

Only transition if a condition is met:

# Pythonic
checking.to(allowed, event="VERIFY", guard="isAdult")

# JSON equivalent
{
    "on": {
        "VERIFY": {
            "target": "allowed",
            "guard": "isAdult",
        },
    },
}

Multiple Guarded Transitions

When the same event can lead to different states depending on conditions, the first matching guard wins:

# Pythonic — use the | operator
verify = checking.to(allowed, event="VERIFY", guard="isAdult") | checking.to(
    rejected, event="VERIFY"
)  # fallback (no guard)

# JSON equivalent — array of transition candidates
{
    "on": {
        "VERIFY": [
            {
                "target": "allowed",
                "guard": "isAdult",
            },
            {
                "target": "rejected",
            },
        ],
    },
}

Self-Transitions

A state can transition to itself — useful for incrementing counters or re-running entry actions:

# Pythonic
counting.to(counting, event="INCREMENT", actions="addOne")

# JSON equivalent
{
    "on": {
        "INCREMENT": {
            "target": "counting",
            "actions": "addOne",
        },
    },
}

Without "reenter": true the state itself is not exited or entered again: its own entry / exit actions do not run and its after timers keep running. A compound (or parallel) state targeting itself does reset its children, though, as in XState v5: its active child states exit and its initial child is entered again (x_b, t, e_a for a state p whose active child was b). With "reenter": true the state itself exits and re-enters too (x_b, x_p, t, e_p, e_a).


🏷️ State Types

Atomic States

Simple leaf states with no children. This is the most common type:

from xstate_statemachine import State, build_machine, SyncInterpreter

idle    = State("idle", initial=True)
loading = State("loading")
done    = State("done")

t1 = idle.to(loading, event="FETCH")
t2 = loading.to(done, event="SUCCESS")

machine = build_machine(
    id="fetcher", states=[idle, loading, done], transitions=[t1, t2]
)
interp = SyncInterpreter(machine).start()
interp.send("FETCH")     # idle -> loading
interp.send("SUCCESS")   # loading -> done
interp.stop()

Compound States (Hierarchical)

States that contain nested child states. The parent is active whenever any of its children are active:

# A "form" state that has sub-states
editing    = State("editing", initial=True)
submitting = State("submitting")
form = State("form", initial=True, states=[editing, submitting])
success = State("success")

t1 = editing.to(submitting, event="SUBMIT")
t2 = form.to(success, event="DONE")

machine = build_machine(
    id="wizard", states=[form, success], transitions=[t1, t2]
)
interp = SyncInterpreter(machine).start()
# active_state_ids: {'wizard.form.editing'}
interp.send("SUBMIT")
# active_state_ids: {'wizard.form.submitting'}
interp.stop()

Parallel States

All child regions are active simultaneously. Each region has its own independent state:

bold   = State("bold",   initial=True)
normal = State("normal")
bold_region = State("fontWeight", parallel=False, states=[bold, normal])

red   = State("red", initial=True)
blue  = State("blue")
color_region = State("fontColor", parallel=False, states=[red, blue])

editor = State("editor", parallel=True, initial=True, states=[bold_region, color_region])

t1 = bold.to(normal, event="TOGGLE_BOLD")
t2 = normal.to(bold,  event="TOGGLE_BOLD")
t3 = red.to(blue, event="TOGGLE_COLOR")
t4 = blue.to(red, event="TOGGLE_COLOR")

machine = build_machine(
    id="textEditor", states=[editor], transitions=[t1, t2, t3, t4]
)
interp = SyncInterpreter(machine).start()
# Active: editor.fontWeight.bold AND editor.fontColor.red
interp.send("TOGGLE_BOLD")
# Active: editor.fontWeight.normal AND editor.fontColor.red
interp.send("TOGGLE_COLOR")
# Active: editor.fontWeight.normal AND editor.fontColor.blue
interp.stop()

Final States

Terminal states. Once a machine enters a final state, it’s done — no more transitions:

idle     = State("idle", initial=True)
loading  = State("loading")
success  = State("success", final=True)
failure  = State("failure")

t1 = idle.to(loading, event="FETCH")
t2 = loading.to(success, event="RESOLVE")
t3 = loading.to(failure, event="REJECT")
t4 = failure.to(loading, event="RETRY")

machine = build_machine(
    id="dataLoader",
    states=[idle, loading, success, failure],
    transitions=[t1, t2, t3, t4],
)
interp = SyncInterpreter(machine).start()
interp.send("FETCH")
interp.send("RESOLVE")
# Machine is now in final state "success" — no further transitions possible
interp.stop()

🐍 Naming: snake_case Python, camelCase JSON

Your machine config follows XState ("actions": "storeUser"); your Python follows PEP 8 (def store_user(...)). You never write the bridge — the library matches names ignoring case and separators, so storeUser, store_user, and even store-user are the same name. Acronyms (logHTTPStatus ↔ log_http_status) and Stately’s inline names (inline:m.a#entry[0] ↔ inline_m_a_entry_0) work too.

flowchart LR
    J["📄 JSON<br/><small>&quot;actions&quot;: &quot;logHTTPStatus&quot;</small>"] --> N["normalise<br/><small>drop _ - . : # [ ] · fold case</small>"]
    P["🐍 Python<br/><small>def log_http_status(...)</small>"] --> N
    N --> K["loghttpstatus<br/><small>one key → one implementation</small>"]

Exact names still take precedence, and two different functions that collide (fetch_data and fetchData) are rejected when the machine is built. The CLI’s xsm gt emits snake_case stubs for exactly this reason.

🔢 Action Execution Order

When a transition fires, actions execute in a strict, predictable order:

  1. Exit actions on the source state (bottom-up for nested states)
  2. Transition actions (defined on the transition itself)
  3. Entry actions on the target state (top-down for nested states)
from xstate_statemachine import State, StateMachine, SyncInterpreter, action

class OrderDemo(StateMachine):
    machine_id = "orderDemo"

    editing    = State("editing", initial=True)
    submitting = State("submitting")

    submit = editing.to(submitting, event="SUBMIT", actions="validate")

    @editing.exit
    def on_exit_editing(self, interpreter, context, event, action_def):
        print("1. EXIT editing (saveDraft)")

    @action
    def validate(self, interpreter, context, event, action_def):
        print("2. TRANSITION action (validate)")

    @submitting.enter
    def on_enter_submitting(self, interpreter, context, event, action_def):
        print("3. ENTRY submitting (showSpinner)")

machine = OrderDemo.create_machine()
interp = SyncInterpreter(machine).start()
interp.send("SUBMIT")
interp.stop()

Output:

1. EXIT editing (saveDraft)
2. TRANSITION action (validate)
3. ENTRY submitting (showSpinner)

Guard Evaluation Order

When multiple transitions share the same event, guards are evaluated top to bottom. The first matching guard wins:

from xstate_statemachine import State, build_machine, SyncInterpreter, guard

checking = State("checking", initial=True)
premium = State("premium")
standard = State("standard")
rejected = State("rejected")

# Order matters! First match wins. The group must be passed to
# build_machine(transitions=...) -- a bare .to() expression is discarded.
verify = (
    checking.to(premium, event="VERIFY", guard="isPremium")
    | checking.to(standard, event="VERIFY", guard="isAdult")
    | checking.to(rejected, event="VERIFY")  # fallback — no guard
)


@guard
def is_premium(context, event):
    return context.get("age", 0) >= 18 and context.get("plan") == "premium"


@guard
def is_adult(context, event):
    return context.get("age", 0) >= 18


machine = build_machine(
    id="accessControl",
    states=[checking, premium, standard, rejected],
    transitions=[verify],
    guards=[is_premium, is_adult],
    context={
        "age": 25,
        "plan": "premium",
    },
)

interp = SyncInterpreter(machine).start()
interp.send("VERIFY")
print(interp.active_state_ids)
# {'accessControl.premium'} — isPremium matched first
interp.stop()

Tip: Always put your most specific guards first and leave a fallback transition (no guard) last to handle the default case.


Context: Your Machine’s Data

Context is a mutable dictionary that travels with the machine across all transitions. Actions read and write it freely.

from xstate_statemachine import State, build_machine, SyncInterpreter, action

counting = State("counting", initial=True)
done = State("done")

inc = counting.to(counting, event="INCREMENT", actions="addOne")
finish = counting.to(done, event="FINISH")


@action
def add_one(interpreter, context, event, action_def):
    context["count"] += 1
    context["history"].append(f"+1 -> {context['count']}")


machine = build_machine(
    id="counter",
    states=[counting, done],
    transitions=[inc, finish],
    actions=[add_one],
    context={
        "count": 0,
        "history": [],
    },
)

interp = SyncInterpreter(machine).start()
interp.send("INCREMENT")
interp.send("INCREMENT")
interp.send("INCREMENT")

print(interp.context["count"])  # 3
print(interp.context["history"])  # ['+1 -> 1', '+1 -> 2', '+1 -> 3']

interp.send("FINISH")
interp.stop()

Tip: Context is just a Python dictionary. You can store any serializable data — numbers, strings, lists, nested dicts. Keep your context flat when possible for easier debugging and serialization.


🔁 The State Machine Lifecycle

Every machine follows the same lifecycle:

flowchart LR
    A["🧩 create<br/><small>define states & rules</small>"] --> B["▶️ start<br/><small>enter initial state</small>"]
    B --> C["📨 send events<br/><small>process transitions</small>"]
    C --> C
    C --> D["⏹️ stop<br/><small>cancel timers, services and children; no exit actions</small>"]

In code:

from xstate_statemachine import State, build_machine, SyncInterpreter

idle   = State("idle", initial=True)
active = State("active")
t1 = idle.to(active, event="ACTIVATE")
t2 = active.to(idle,  event="DEACTIVATE")

# 1. CREATE — define the machine
machine = build_machine(
    id="lifecycle", states=[idle, active], transitions=[t1, t2]
)

# 2. START — enter the initial state
interp = SyncInterpreter(machine).start()
print(interp.active_state_ids)   # {'lifecycle.idle'}
print(interp.is_running)         # True

# 3. SEND EVENTS — drive state changes
interp.send("ACTIVATE")
print(interp.active_state_ids)   # {'lifecycle.active'}

interp.send("DEACTIVATE")
print(interp.active_state_ids)   # {'lifecycle.idle'}

# 4. STOP — clean up
interp.stop()
print(interp.is_running)         # False

Why State Machines?

Consider a simple door that can be opened, closed, and locked. Here’s the traditional approach vs. the state machine approach:

Without State Machines (if/else)

class Door:
    def __init__(self):
        self.is_open = False
        self.is_locked = False

    def open(self):
        if self.is_locked:
            print("Can't open — locked!")
        elif self.is_open:
            print("Already open!")
        else:
            self.is_open = True

    def close(self):
        if not self.is_open:
            print("Already closed!")
        else:
            self.is_open = False

    def lock(self):
        if self.is_open:
            print("Can't lock — door is open!")
        elif self.is_locked:
            print("Already locked!")
        else:
            self.is_locked = True

    def unlock(self):
        if not self.is_locked:
            print("Not locked!")
        else:
            self.is_locked = False

# Problems:
# - What if someone sets is_open=True AND is_locked=True? Invalid state!
# - Every new feature adds more if/else branches
# - Hard to visualize the full set of valid transitions
# - No protection against impossible states

With a State Machine

from xstate_statemachine import State, build_machine, SyncInterpreter

closed   = State("closed", initial=True)
opened   = State("opened")
locked   = State("locked")

t1 = closed.to(opened, event="OPEN")
t2 = closed.to(locked, event="LOCK")
t3 = opened.to(closed, event="CLOSE")
t4 = locked.to(closed, event="UNLOCK")

machine = build_machine(
    id="door",
    states=[closed, opened, locked],
    transitions=[t1, t2, t3, t4],
)
interp = SyncInterpreter(machine).start()

interp.send("OPEN")     # closed -> opened
interp.send("LOCK")     # ignored! No LOCK transition from "opened"
interp.send("CLOSE")    # opened -> closed
interp.send("LOCK")     # closed -> locked
interp.send("OPEN")     # ignored! No OPEN transition from "locked"
interp.send("UNLOCK")   # locked -> closed
interp.stop()

# Benefits:
# - Impossible states are impossible (can't be open AND locked)
# - An event the current state does not handle is a no-op (opt into `strict` to make an undeclared type raise)
# - The full behavior is visible in the transition definitions
# - Easy to add new states or events without breaking existing logic

One Algorithm, Two Engines

Interpreter (async) and SyncInterpreter (sync) used to carry two independent copies of the transition logic – each engine reimplementing action execution, guard evaluation, event selection, and error handling with its own subtle differences. As of #60, there is exactly one implementation of the core algorithm, living in BaseInterpreter. Each engine supplies only a small set of engine-specific leaves:

  • how to invoke a single user action (await on the async engine, a plain call on the sync engine);
  • how to invoke a single service;
  • how to spawn or stop a child actor.

Everything else – resolving built-in actions, running the action list in order, containing an action’s exception, applying actionErrorPolicy, firing on_action_execute / on_action_error / on_transition plugin hooks, deciding whether an event is unhandled – is written once and shared.

What this guarantees you as a user:

  • Identical semantics on both engines. A machine that behaves a particular way under SyncInterpreter behaves the same way under Interpreter (modulo the unavoidable sync/async boundary – an async def action or service still requires the async engine and raises NotSupportedError on the sync one). Guard order, action order, error policies, and unhandled-event handling are not “close enough” between engines; they are the same code path.
  • A bug fixed once is fixed everywhere. There is no second copy of the transition logic to have drifted, and no risk of a fix landing on one engine but not the other.
  • Choosing an engine is purely an I/O concern – do you need non-blocking async services and timers, or immediate, thread-free execution? – never a behavioral one.

The Microstep / Macrostep Model

Processing a single external event is a macrostep, made of one or more microsteps, mirroring the SCXML processing model (#36):

  1. An external event (from send(), a fired after timer, or a completed invoke) is taken off the inbox. This starts a macrostep.
  2. The machine evaluates transitions and runs their actions. An action can itself raise() a new event to this same machine – that event does not go back onto the external inbox. It goes onto an internal queue. The done.state.<id> event the engine generates when a compound or parallel state completes goes onto the same internal queue, so its onDone transition is taken before any external event that is already waiting (before 0.12.0 it was appended to the inbox, behind such events).
  3. Before the interpreter looks at the next external event, it drains the internal queue completely, one microstep at a time, in the order events were raised. Each internal event may itself raise further internal events, which are appended and processed in turn.
  4. Only once the internal queue is empty does the macrostep end and the interpreter return to the external inbox for the next event.
from xstate_statemachine import State, build_machine, SyncInterpreter, raise_

step_one = State("stepOne", initial=True)
step_two = State(
    "stepTwo",
    entry=[
        raise_(
            {
                "type": "NEXT",
            }
        )
    ],
)
step_three = State("stepThree")

# Entering "stepTwo" immediately raises NEXT to itself -- processed as part
# of the SAME macrostep, before any externally queued event is looked at.
machine = build_machine(
    id="microsteps",
    states=[step_one, step_two, step_three],
    transitions=[
        step_one.to(step_two, event="START"),
        step_two.to(step_three, event="NEXT"),
    ],
)

interp = SyncInterpreter(machine).start()
interp.send("START")
# By the time send() returns, only the macrostep for "START" has been
# processed -- but internally, entering stepTwo could `raise_({"type": "NEXT"})`
# and the machine would already be sitting in stepThree.
interp.stop()

This is why an event you send() is never interleaved with the internal consequences of a previous event: the internal queue always drains first, so external events observe a machine that has already settled from its own self-raised events.


🚀 Next Steps

Now that you understand the building blocks: