JSON Configuration Reference
Every field in the XState JSON format, fully documented with examples.
XState JSON is the universal format for defining state machines. You can design machines visually at stately.ai, export JSON, and run them directly with this library — or write the JSON by hand.
This page documents every field in the configuration format, with complete, runnable examples.
🧱 Complete JSON Structure
flowchart TB
M["machine<br/><small>id · initial · context · policies</small>"]
M --> S1["state <b>idle</b><br/><small>entry · exit · on · after</small>"]
M --> S2["state <b>loading</b><br/><small>invoke: fetchUser → onDone / onError</small>"]
M --> S3["state <b>done</b><br/><small>type: final · output</small>"]
S1 -- "on: LOAD" --> S2
S2 -- "onDone" --> S3
S2 -- "onError" --> S1
Here is a fully annotated machine configuration showing all top-level and state-level fields:
{
"id": "orderMachine",
"initial": "idle",
"context": {
"retries": 0,
"orderId": null,
"items": [],
"error": null
},
"states": {
"idle": {
"on": {
"PLACE_ORDER": {
"target": "validating",
"actions": "captureOrder",
"guard": "hasItems"
}
},
"entry": "resetForm",
"exit": "clearErrors"
},
"validating": {
"always": [
{ "target": "processing", "guard": "isValid" },
{ "target": "idle", "actions": "showValidationError" }
]
},
"processing": {
"invoke": {
"src": "submitOrder",
"onDone": {
"target": "confirmed",
"actions": "storeOrderId"
},
"onError": {
"target": "error",
"actions": "storeError"
}
},
"after": {
"10000": "error"
}
},
"confirmed": {
"type": "final"
},
"error": {
"on": {
"RETRY": {
"target": "processing",
"guard": "hasRetriesLeft",
"actions": "incrementRetry"
},
"CANCEL": "idle"
}
}
}
}
This machine has:
id— a unique identifier for the machineinitial— the state the machine starts incontext— mutable data that travels with the machinestates— a map of every state and its configuration- Entry/exit actions, guards, services, delayed transitions, eventless transitions, and a final state
📚 Field-by-Field Reference
Top-Level Fields
| Field | Type | Required | Description |
|---|---|---|---|
id |
string |
Yes | Unique machine identifier. Used as a prefix in state IDs (e.g., "orderMachine.idle"). |
initial |
string |
Yes | The name of the starting state. Must match a key in states. |
context |
object |
No | Initial mutable data. Accessible in actions, guards, and services. Deep-copied on each interpreter start. |
states |
object |
Yes | A map of state name → state configuration. At least one state is required. |
strict |
bool |
No | Default false. When true, sending an event type the machine has never declared anywhere raises UnknownEventError. See Strict Mode. |
strictTargets |
bool |
No | Default false. When true, a plain (non-dotted) transition target must name a sibling or ancestor-scope state — it will no longer fall back to matching an unrelated state elsewhere in the tree that happens to share the same trailing id segment. |
maxIterations |
int |
No | Default 1000. Runaway guard for work the machine generates for itself: eventless (always) microsteps while settling, and unbroken chains of self-raise / self-send() / sync done.invoke events. Counted per chain — it resets whenever a step generates nothing — so a batch of any size of independent user events is always processed in full on both engines; only a genuinely self-feeding loop trips it, and only the generated tail is dropped. A trip is observable (0.8.1): the triggering send(wait=True) receipt carries a RunawayChainError, interp.last_transition_ok is False / interp.last_error is set, and on_event_dropped fires with reason="chain_budget" for each discarded event. Engine completions (done.invoke, error.platform) are never discarded. |
spawnBlockingTimeout |
number |
No | Milliseconds a spawn_blocking_<key> action waits for the child to reach a final state before giving up, on both engines. Default 30000 (30 s) — never unbounded, so a child that never finishes cannot wedge the parent mid-transition. |
actionErrorPolicy |
"continue" \| "rollback" \| "fail" |
No | Default "continue". What happens when an entry/exit/transition action raises: "continue" logs and proceeds (the 0.7.x behavior, and the current default — planned to change to "rollback" in 1.0), "rollback" undoes the transition, "fail" puts the interpreter into the terminal "error" status. |
guardErrorPolicy |
"false" \| "true" \| "raise" |
No | Default "false". What happens when a guard function raises: "false" treats the guard as not passing, "true" as passing, "raise" propagates the exception. Every outcome fires the on_guard_error plugin hook first. |
onUnhandled |
"ignore" \| "defer" \| "error" |
No | Default "ignore". Policy applied when a known event (declared somewhere in the machine) matches no transition in the currently active state(s). See onUnhandled: what happens to a known-but-unmatched event below. |
output |
any |
No | Machine-level output value, resolved when a top-level final state is reached. Surfaced on the generated done.state.* event. |
Example — minimal machine:
{
"id": "toggle",
"initial": "off",
"states": {
"off": { "on": { "FLIP": "on" } },
"on": { "on": { "FLIP": "off" } }
}
}
Example — with context:
{
"id": "counter",
"initial": "counting",
"context": {
"count": 0,
"maxCount": 100,
"history": []
},
"states": {
"counting": {
"on": {
"INCREMENT": { "actions": "addOne" },
"DECREMENT": { "actions": "subtractOne" },
"RESET": { "actions": "resetCount" }
}
}
}
}
State Fields
Every value in the states object is a state configuration with the following optional fields:
| Field | Type | Description |
|---|---|---|
on |
object |
Map of event name → transition(s). The core of state machine behavior. |
entry |
string \| string[] |
Action(s) to run when entering this state. |
exit |
string \| string[] |
Action(s) to run when leaving this state. |
invoke |
object \| object[] |
Service(s) to start when entering this state. |
after |
object |
Delayed transitions: { "milliseconds": target_or_transition }. |
type |
string |
One of "atomic", "compound", "parallel", "final", or "history". Default: "atomic". |
initial |
string |
Initial child state name (required for compound states). |
states |
object |
Nested child state configurations (makes this a compound state). |
onDone |
string \| object |
Transition when a compound state’s child reaches a final state. |
always |
object \| object[] |
Eventless (transient) transitions — evaluated immediately on entry. |
tags |
string \| string[] |
Arbitrary labels for this state, queryable at runtime via interpreter.has_tag(tag) and interpreter.tags. Useful for UI concerns like “is a spinner showing” without hardcoding state names. |
meta |
object |
Arbitrary metadata attached to the state, retrievable via interpreter.get_meta() (returns a {state_id: meta} map for every currently active state that declares one). |
description |
string |
Free-text documentation for the state. Not used by the runtime — purely informational, e.g. for tooling or generated docs. |
history |
"shallow" \| "deep" |
Only meaningful on a state with "type": "history" (see below). Default "shallow". |
output |
any |
Only meaningful on a "type": "final" state. The value surfaced on the done.state.* / done.invoke.* event when this final state is reached. |
Example — state with all fields:
"loading": {
"entry": ["showSpinner", "logStart"],
"exit": "hideSpinner",
"invoke": {
"src": "fetchData",
"onDone": { "target": "success", "actions": "storeResult" },
"onError": { "target": "failure", "actions": "storeError" }
},
"on": {
"CANCEL": "idle"
},
"after": {
"15000": { "target": "failure", "actions": "logTimeout" }
}
}
Example — tags, meta, and description:
tags and meta are queryable at runtime through the interpreter; description is documentation only.
from xstate_statemachine import create_machine, SyncInterpreter
config = {
"id": "order",
"initial": "pending",
"states": {
"pending": {
"tags": ["active", "billable"],
"meta": {"ui": {"color": "blue"}},
"description": "Waiting for payment confirmation.",
"on": {"FILL": "filled"},
},
"filled": {"type": "final"},
},
}
machine = create_machine(config)
interp = SyncInterpreter(machine).start()
print(interp.has_tag("billable")) # True
print(interp.tags) # {'active', 'billable'}
print(interp.get_meta()) # {'order.pending': {'ui': {'color': 'blue'}}}
➡️ Transition Formats
Transitions are defined inside a state’s on field. The library supports four formats, from simplest to most expressive.
Format 1: Simple String Shorthand
The most concise form — just the target state name:
"on": {
"CLICK": "active",
"HOVER": "highlighted",
"RESET": "idle"
}
No guard, no actions — just move to the target state.
Format 2: Object with Options
Add a target, guard, and/or actions:
"on": {
"SUBMIT": {
"target": "submitting",
"guard": "isFormValid",
"actions": "logSubmission"
}
}
| Property | Type | Description |
|---|---|---|
target |
string |
Destination state name. |
guard |
string |
Guard function name — transition only fires if this returns true. |
actions |
string \| string[] |
Action(s) to execute during the transition. |
Format 3: Array Form (Multiple Transitions)
When an event has multiple possible outcomes, use an array. The first transition whose guard passes wins:
"on": {
"SUBMIT": [
{ "target": "premium", "guard": "isPremiumUser" },
{ "target": "standard", "guard": "isVerified" },
{ "target": "rejected" }
]
}
Tip: Always put the most specific guard first. The last entry without a guard acts as the fallback (default) transition.
Format 4: Multiple Actions
A single transition can run multiple actions:
"on": {
"CHECKOUT": {
"target": "processing",
"actions": ["validateCart", "captureAddress", "startPayment"]
}
}
Actions execute in order — validateCart first, then captureAddress, then startPayment.
Combining all formats:
"on": {
"LOGIN": [
{
"target": "admin",
"guard": "isAdmin",
"actions": ["logLogin", "loadAdminDashboard"]
},
{
"target": "dashboard",
"actions": ["logLogin", "loadUserDashboard"]
}
]
}
♾️ Eventless Transitions (always)
Eventless transitions fire immediately when a state is entered — no event needed. They are evaluated in order, and the first matching guard wins.
Router Example
{
"id": "router",
"initial": "checking",
"context": { "role": "admin", "authenticated": true },
"states": {
"checking": {
"always": [
{ "target": "adminPanel", "guard": "isAdmin" },
{ "target": "userDashboard", "guard": "isUser" },
{ "target": "login" }
]
},
"adminPanel": {},
"userDashboard": {},
"login": {}
}
}
When the machine enters "checking":
- It evaluates
isAdmin— iftrue, immediately transitions to"adminPanel". - Otherwise, evaluates
isUser— iftrue, transitions to"userDashboard". - Otherwise, falls through to
"login"(no guard = always matches).
Note: Eventless transitions happen synchronously during state entry. The machine never “rests” in the
"checking"state — it passes through instantly.
Eventless with Actions
"checking": {
"always": [
{
"target": "premium",
"guard": "hasPremiumPlan",
"actions": "loadPremiumFeatures"
},
{
"target": "free",
"actions": "loadBasicFeatures"
}
]
}
📞 Invoke / Service Fields
The invoke field starts an async operation (service) when a state is entered. When the service resolves or rejects, the machine transitions via onDone or onError.
Invoke Field Reference
| Field | Type | Required | Description |
|---|---|---|---|
src |
string |
Yes | The name of the service function to call. |
onDone |
string \| object |
No | Transition when the service resolves successfully. |
onError |
string \| object |
No | Transition when the service throws an error. |
id |
string |
No | Optional identifier for the invoked service. Defaults to the hosting state’s id if omitted (so give anonymous invokes in the same state distinct ids if you need to address them uniquely). |
input |
any |
No | Static data — or a callable resolved per-spawn — passed to the invoked child as its input. |
systemId |
string |
No | Registers the spawned child in the actor system under this name, so sendTo/forward_to can address it from anywhere in the tree. |
Basic Invoke
"loading": {
"invoke": {
"src": "fetchUserProfile",
"onDone": {
"target": "loaded",
"actions": "storeProfile"
},
"onError": {
"target": "error",
"actions": "storeError"
}
}
}
Invoke with ID
"polling": {
"invoke": {
"id": "pollService",
"src": "pollForUpdates",
"onDone": {
"target": "updated",
"actions": ["storeUpdate", "logRefresh"]
},
"onError": "error"
}
}
Multiple Invocations
A state can invoke multiple services simultaneously using an array:
"initializing": {
"invoke": [
{
"src": "loadConfig",
"onDone": { "actions": "storeConfig" }
},
{
"src": "loadUser",
"onDone": { "actions": "storeUser" }
}
]
}
Note: The
onDoneevent carries the return value of the service. Access it in your action viaevent.data.
⏱️ After (Delayed Transitions)
The after field defines timer-based automatic transitions. Keys are milliseconds (as strings), values are target states or full transition objects.
Simple Timeout
"notification": {
"entry": "showToast",
"after": {
"5000": "hidden"
}
}
After 5 seconds, the machine automatically transitions from "notification" to "hidden".
Timeout with Guard
"warning": {
"after": {
"30000": {
"target": "expired",
"guard": "noUserActivity"
}
},
"on": {
"EXTEND": "active"
}
}
Multiple Timers
A state can have multiple after timers running simultaneously:
"monitoring": {
"after": {
"5000": { "target": "monitoring", "actions": "heartbeat" },
"60000": { "target": "stale", "guard": "noRecentData" },
"300000": "timeout"
}
}
This state:
- Sends a heartbeat every 5 seconds (self-transition re-enters the state, restarting all timers).
- Transitions to
"stale"after 60 seconds if no recent data. - Hard-timeouts at 5 minutes regardless.
Session Timeout Example
{
"id": "sessionTimeout",
"initial": "active",
"states": {
"active": {
"after": { "300000": "warning" },
"on": { "ACTIVITY": "active" }
},
"warning": {
"after": { "30000": "expired" },
"on": { "EXTEND": "active" }
},
"expired": {
"type": "final"
}
}
}
Tip: The
ACTIVITYevent on"active"triggers a self-transition, which restarts the 5-minute timer. This is how you implement “idle timeout with reset on activity”.
🏷️ State Types
Every state has a type that determines its behavior:
| Type | Description | Has Children? | Outgoing Transitions? |
|---|---|---|---|
"atomic" |
Simple leaf state (default). | No | Yes |
"compound" |
Parent state with nested children. Automatically inferred when states is present. |
Yes | Yes |
"parallel" |
All child regions active simultaneously. | Yes | Yes |
"final" |
Terminal state — the machine (or region) is done. | No | No |
"history" |
Pseudo-state that, when targeted, restores a previously-active child configuration of its parent instead of being entered itself. | No | No |
Atomic (default)
"idle": {
"on": { "START": "running" }
}
No type field needed — atomic is the default.
Final
"completed": {
"type": "final"
}
When a final state is entered inside a compound state, it triggers a done.state.* event on the parent.
Parallel
"playing": {
"type": "parallel",
"states": {
"video": {
"initial": "loading",
"states": {
"loading": { "on": { "LOADED": "showing" } },
"showing": {}
}
},
"audio": {
"initial": "muted",
"states": {
"muted": { "on": { "UNMUTE": "playing" } },
"playing": { "on": { "MUTE": "muted" } }
}
}
}
}
History
A history state is never entered itself — targeting it restores whichever child of its parent was active when the parent was last exited. history (the config key, default "shallow") controls the depth: "shallow" restores only the immediate child, "deep" restores the full nested configuration. If the parent has never been exited before, the parent’s own initial state is entered instead.
from xstate_statemachine import create_machine, SyncInterpreter
config = {
"id": "player",
"initial": "off",
"states": {
"off": {"on": {"POWER": "on.hist"}},
"on": {
"type": "compound",
"initial": "playing",
"on": {"POWER": "off"},
"states": {
"playing": {"on": {"PAUSE": "paused"}},
"paused": {"on": {"PLAY": "playing"}},
"hist": {"type": "history", "history": "shallow"},
},
},
},
}
machine = create_machine(config)
interp = SyncInterpreter(machine).start()
interp.send("POWER") # off -> on.playing
interp.send("PAUSE") # on.playing -> on.paused
interp.send("POWER") # on.paused -> off
interp.send("POWER") # off -> on.hist -> restores on.paused
print(interp.current_state_ids) # {'player.on.paused'}
🪆 Nested (Compound) States
When a state has a states field, it becomes a compound state. It must also have an initial field to specify which child state is entered first.
Example: Authentication Flow
{
"id": "auth",
"initial": "loggedOut",
"states": {
"loggedOut": {
"on": { "LOGIN": "loggedIn" }
},
"loggedIn": {
"initial": "dashboard",
"states": {
"dashboard": {
"on": {
"VIEW_PROFILE": "profile",
"VIEW_SETTINGS": "settings"
}
},
"profile": {
"on": { "BACK": "dashboard" }
},
"settings": {
"on": { "BACK": "dashboard" }
}
},
"on": {
"LOGOUT": "loggedOut"
}
}
}
}
Key behavior: The LOGOUT event on the parent "loggedIn" state catches the event no matter which child state is active. This is the power of hierarchy — parent transitions apply to all children.
Multi-Level Nesting
States can be nested multiple levels deep:
"app": {
"initial": "main",
"states": {
"main": {
"initial": "home",
"states": {
"home": { "on": { "NAV_PROFILE": "profile" } },
"profile": { "on": { "NAV_HOME": "home" } }
}
}
}
}
onDone for Compound States
When a compound state’s child enters a final state, the parent can react via onDone:
{
"id": "wizard",
"initial": "step1",
"states": {
"step1": {
"initial": "editing",
"states": {
"editing": {
"on": { "NEXT": "complete" }
},
"complete": { "type": "final" }
},
"onDone": "step2"
},
"step2": {
"initial": "editing",
"states": {
"editing": {
"on": { "NEXT": "complete" }
},
"complete": { "type": "final" }
},
"onDone": "finished"
},
"finished": { "type": "final" }
}
}
When step1.complete is entered (a final state), it fires a done.state.step1 event, which triggers onDone and moves the machine to step2.
Note:
onDonesupports the same formats as transitions — a string target, or an object withtarget,guard, andactions.
Complete Real-World Example: Fetch Machine
This machine models a complete data-fetching flow with retries, timeout, and context tracking:
{
"id": "fetchMachine",
"initial": "idle",
"context": {
"data": null,
"error": null,
"retries": 0,
"maxRetries": 3,
"lastFetchedAt": null
},
"states": {
"idle": {
"entry": "resetError",
"on": {
"FETCH": {
"target": "loading",
"actions": "logFetchStart"
}
}
},
"loading": {
"entry": "showSpinner",
"exit": "hideSpinner",
"invoke": {
"src": "fetchData",
"onDone": {
"target": "success",
"actions": ["storeData", "recordTimestamp"]
},
"onError": {
"target": "error",
"actions": "storeError"
}
},
"after": {
"15000": {
"target": "error",
"actions": "logTimeout"
}
},
"on": {
"CANCEL": {
"target": "idle",
"actions": "logCancellation"
}
}
},
"success": {
"entry": "notifySuccess",
"on": {
"REFRESH": "loading",
"RESET": {
"target": "idle",
"actions": "clearData"
}
}
},
"error": {
"entry": "notifyError",
"on": {
"RETRY": [
{
"target": "loading",
"guard": "hasRetriesLeft",
"actions": "incrementRetry"
},
{
"target": "failed",
"actions": "logMaxRetries"
}
],
"RESET": {
"target": "idle",
"actions": ["clearData", "resetRetries"]
}
}
},
"failed": {
"type": "final"
}
}
}
Running It
from xstate_statemachine import create_machine, SyncInterpreter, MachineLogic
class FetchLogic(MachineLogic):
# Actions
def reset_error(self, interpreter, context, event, action_def):
context["error"] = None
def show_spinner(self, interpreter, context, event, action_def):
print("⏳ Loading...")
def hide_spinner(self, interpreter, context, event, action_def):
print(" Spinner hidden")
def store_data(self, interpreter, context, event, action_def):
context["data"] = event.data
def record_timestamp(self, interpreter, context, event, action_def):
from datetime import datetime
context["lastFetchedAt"] = datetime.now().isoformat()
def store_error(self, interpreter, context, event, action_def):
context["error"] = str(event.data)
def increment_retry(self, interpreter, context, event, action_def):
context["retries"] += 1
print(f"🔄 Retry #{context['retries']}")
def log_fetch_start(self, interpreter, context, event, action_def):
print("📡 Fetch started")
def log_timeout(self, interpreter, context, event, action_def):
context["error"] = "Request timed out"
def log_cancellation(self, interpreter, context, event, action_def):
print("❌ Fetch cancelled")
def log_max_retries(self, interpreter, context, event, action_def):
print("💀 Max retries reached")
def notify_success(self, interpreter, context, event, action_def):
print(f"✅ Data loaded: {context['data']}")
def notify_error(self, interpreter, context, event, action_def):
print(f"⚠️ Error: {context['error']}")
def clear_data(self, interpreter, context, event, action_def):
context["data"] = None
def reset_retries(self, interpreter, context, event, action_def):
context["retries"] = 0
# Guards
def has_retries_left(self, context, event):
return context["retries"] < context["maxRetries"]
# Services
def fetch_data(self, interpreter, context, event):
import requests
resp = requests.get("https://api.example.com/data")
return resp.json()
config = { ... } # The JSON config above
machine = create_machine(config, logic=FetchLogic())
interp = SyncInterpreter(machine).start()
interp.send("FETCH") # idle -> loading -> (service runs) -> success or error
interp.send("RETRY") # error -> loading (if retries left)
interp.stop()
print(interp.context)
Strict Mode: Unknown Events and Payload Schemas
By default, sending an event type that no state in the machine ever declares is a silent no-op — this is XState’s actor semantics, and it stays correct for events a particular state simply doesn’t care about (the same default, onUnhandled: "ignore", also governs known-but-unmatched events; see below). But it makes a second, very different case invisible too: an event that NOTHING in the machine has ever heard of, which is almost always a typo or an outdated producer. strict mode turns that second case into an exception raised at the send() call site.
Enabling strict mode
Enable it with the strict config key, or with the strict= keyword argument on either interpreter constructor. The constructor argument wins when both are given:
from xstate_statemachine import create_machine, SyncInterpreter
config = {
"id": "order",
"initial": "pending",
"strict": True,
"states": {
"pending": {"on": {"FILL": "filled", "CANCEL": "cancelled"}},
"filled": {"type": "final"},
"cancelled": {"type": "final"},
},
}
machine = create_machine(config)
interp = SyncInterpreter(machine).start()
interp.send("FILLL") # typo!
xstate_statemachine.exceptions.UnknownEventError: Event 'FILLL' is not
declared by machine 'order'. Known events: CANCEL, FILL. Did you mean 'FILL'?
UnknownEventError.event_type, .machine_id, and .known (the sorted declared descriptor set) are available on the exception for programmatic handling.
Strict mode can also be turned on per-interpreter, overriding a machine that was not built with strict: True:
machine = create_machine(config) # not strict at the config level
interp = SyncInterpreter(machine, strict=True).start()
On the async engine, Interpreter.send() raises UnknownEventError synchronously, before the event is queued — even if the coroutine it returns is never awaited. This matters because a run-loop error surfacing later, inside the fire-and-forget processing loop, could never be caught by the caller:
i = await Interpreter(create_machine(config), strict=True).start()
try:
i.send("NOPE") # not awaited -- still raises here
except UnknownEventError:
...
assert i.queue_depth == 0 # never reached the inbox
What counts as “known”
A machine’s declared descriptor set (MachineNode.known_events, a FrozenSet[str]) is built once, lazily, from every on key anywhere in the tree, including:
- Exact event names (
"FILL") - Partial wildcard descriptors (
"mouse.*") — these match by dot-segment:"mouse.*"makesmouse.clickknown but notkeyboard.press - The bare
"*"descriptor — makes every event known, disabling the unknown-event check entirely for that machine - Every
afterdelay’s generated timer event - Every
invoke’s generateddone.invoke.<id>anderror.platform.<id>
Engine-synthesised events are always known regardless of what the machine declares — see Engine events are always “known”; yours are checked below.
Use Machine.is_known_event(event_type) to run the same check yourself:
machine.is_known_event("FILL") # True: exact match
machine.is_known_event("mouse.click") # True: partial match
machine.is_known_event("done.invoke.job") # True: generated by invoke
machine.is_known_event("xstate.init") # True: engine event, always known
machine.is_known_event("nope") # False
Declared-but-unhandled events are still ignored (by default)
strict only rejects events the machine has never declared anywhere. An event that’s declared in one state but not handled by the state currently active is a separate case, governed by the onUnhandled policy described below — whose default ("ignore") is a normal, silent no-op:
config = {
"id": "order",
"initial": "pending",
"states": {
"pending": {"on": {"FILL": "filled", "CANCEL": "cancelled"}},
"filled": {"on": {"SHIP": "shipped"}},
"shipped": {"type": "final"},
"cancelled": {"type": "final"},
},
}
interp = SyncInterpreter(create_machine(config)).start() # strict via config
interp.send("FILL") # pending -> filled
interp.send("CANCEL") # known (declared in `pending`), but `filled` has no handler -> ignored
print(interp.current_state_ids) # {'order.filled'}
Note:
filledis given an unrelatedon: {"SHIP": ...}handler here (rather than being a bare"type": "final"state) so the example actually demonstrates “declared elsewhere, not handled here.” Afinalstate has noonat all, andSyncInterpreterauto-stops once the machine reaches a top-level final state — so sendingCANCELto an already-finalfilledwould be a no-op because the interpreter had stopped, not because of any unhandled-event policy.
Internal raise events
A {"type": "raise", "params": {"event": "..."}} action is checked in two places on a strict machine:
- At build time (0.8.1). If the raised event is a literal — a string or a
{"type": ...}dict —create_machine()verifies it againstknown_eventsand raisesInvalidConfigError(with a Did you mean …? suggestion) when nothing handles it. A typo in your config is a configuration error and should never reach runtime. - At runtime. A dynamic event (a callable producing the event) is checked by the same
_check_strictpath assend()when the action executes. Be aware that under the defaultactionErrorPolicy: "continue"the resultingUnknownEventErroris contained like any other action failure: it is logged, reported throughon_action_error/on_transition_failed, setslast_transition_ok = False— and the transition still commits. If you want a runtime strict violation to abort the transition, pairstrictwithactionErrorPolicy: "rollback"or"fail".
Engine events are always “known”; yours are checked
Events the engine synthesises are never “unknown” — strict is never tripped by a done.invoke.<id>, error.platform.<id>, after.<ms> or xstate.* event the machine produced for itself. Since 0.8.1 that exemption is by provenance (the engine flags the events it mints), so a user-sent event is checked whatever it is called: interp.send("done.typo") on a strict machine raises UnknownEventError exactly like interp.send("TYPO") (#79). Machine.is_known_event() mirrors this for names: only the exact engine shapes (done.invoke., done.state., error.platform., after., xstate.) are implicitly known; a bare done. prefix is not.
send_threadsafe() is covered too
Since 0.8.1 Interpreter.send_threadsafe() applies the same strict and event_schemas checks as send(), raising on the calling thread before the event is queued. In 0.8.0 it bypassed both.
MachineLogic(strict=True) — a different strict
Unrelated to event validation, MachineLogic(strict=True) (0.8.1) makes subclass auto-registration refuse any undecorated public method — InvalidConfigError at construction instead of an arity-based guess plus a UserWarning. Decorate every method with @action / @guard / @service, or prefix helpers with _.
onUnhandled: what happens to a known-but-unmatched event
This silent ignore is itself the default of a separate, configurable policy: onUnhandled, a top-level config key distinct from strict. Where strict governs events the machine has never declared, onUnhandled governs events that are declared somewhere but don’t match any transition in the state(s) currently active. It accepts three values:
| Value | Behavior |
|---|---|
"ignore" (default) |
Silent no-op — the 0.7.x behavior shown above. |
"defer" |
The event is held in a bounded FIFO buffer (DEFER_MAX = 1000) and replayed at the head of the queue after the next state change. If the buffer is full, the oldest deferred event is evicted to make room. |
"error" |
Puts the interpreter into the terminal "error" status with a UnhandledEventError on interpreter.error. |
from xstate_statemachine import create_machine, SyncInterpreter
config = {
"id": "order",
"initial": "pending",
"onUnhandled": "defer",
"states": {
"pending": {"on": {"START_SHIP": "filled"}},
"filled": {"on": {"SHIP": "shipped"}},
"shipped": {"type": "final"},
},
}
machine = create_machine(config)
interp = SyncInterpreter(machine).start()
interp.send("SHIP") # unmatched in 'pending' -> deferred, not lost
print(interp.deferred_count) # 1
interp.send("START_SHIP") # pending -> filled; deferred SHIP replays here
print(interp.current_state_ids) # {'order.shipped'}
Switching the same config’s onUnhandled to "error" and sending an unmatched event instead sets interpreter.status == "error" and interpreter.error to a UnhandledEventError.
Opt-in payload schemas
Independent of strict, create_machine(..., event_schemas={...}) lets you validate an event’s payload shape before it reaches any guard or action. A schema is any object exposing validate(payload), or a plain callable — either should raise on a bad payload:
class Fill:
@staticmethod
def validate(payload):
if not isinstance(payload.get("qty"), (int, float)):
raise ValueError("qty: Input should be a valid number")
machine = create_machine(config, event_schemas={"FILL": Fill})
interp = SyncInterpreter(machine).start()
interp.send("FILL", qty="not-a-number")
xstate_statemachine.exceptions.InvalidEventPayloadError: payload for 'FILL'
failed validation: qty: Input should be a valid number
A plain callable works too:
def check_qty(payload):
if "qty" not in payload:
raise KeyError("qty")
machine = create_machine(config, event_schemas={"FILL": check_qty})
InvalidEventPayloadError carries .event_type and .cause (the original exception the validator raised). Schema validation runs regardless of strict, and — like the unknown-event check — happens before the event is queued, so a rejected payload never reaches the machine’s context.
Tips: Common Mistakes and Best Practices
Tip: Always give your machine a descriptive
id. State IDs are prefixed with it (e.g.,"fetchMachine.loading"), which makes debugging and logging much clearer.
Tip: Use context for data that changes — like counters, user objects, and error messages. Use states for modes — like “idle”, “loading”, “error”.
Warning: Don’t put the same event name in both a parent and a child state unless you intend for the child to “shadow” the parent’s handler. The child’s
onhandler takes priority.
Warning: Final states cannot have outgoing transitions (
on), child states (states), or delayed transitions (after). If you need to leave a final state, redesign your state hierarchy so the final state is inside a compound state with anonDonehandler.
Tip: When using multiple guarded transitions (array form), always include a fallback transition without a guard as the last entry. Otherwise, the event is silently dropped if no guard matches.
Note: JSON doesn’t support comments. If you need to annotate your config, keep it in a Python dict or use a
.jsoncfile and strip comments before parsing.
Tip: Start with the Stately visual editor to design your machine, then export the JSON. It validates your config and catches structural errors before you write any code.
Quick Reference Cheat Sheet
| Where | Keys |
|---|---|
| 🏠 Top level | id · initial · context · states · strict · strictTargets · maxIterations · spawnBlockingTimeout · actionErrorPolicy · guardErrorPolicy · onUnhandled · output |
| 🔲 State | on · entry · exit · invoke · after · type · initial · states · onDone · always · tags · meta · description · history · output |
| 🏷️ State types | "atomic" (default) · "compound" · "parallel" · "final" · "history" |
| 📞 Invoke | src · onDone · onError · id · input · systemId |
| Transition form | Example | Notes |
|---|---|---|
| String shorthand | "EVENT": "target" |
just move |
| Object | "EVENT": { "target", "guard", "actions" } |
guard + actions |
| Array | "EVENT": [ { … }, { … } ] |
first passing guard wins |
| Eventless | "always": [ { "target", "guard" }, … ] |
evaluated on entry and after every transition |
| Delayed | "after": { "5000": "target" } |
or the object form with guard/actions |
| Strictness | How |
|---|---|
| Unknown event types raise | "strict": true in JSON, or Interpreter(machine, strict=True) |
| Payload validation | create_machine(config, event_schemas={"EVENT": schema_or_callable}) |
| Unresolvable targets rejected at build | create_machine(config, strict_targets=True) (the only mode in 1.0) |