(processing-model)= (processing model)=
The engine processes events following the SCXML run-to-completion (RTC) model: each event is fully processed — all callbacks executed, all states entered/exited — before the next event starts. This guarantees the system is always in a consistent state when a new event arrives.
Run to completion — SCXML adheres to a run to completion semantics in the sense that an external event can only be processed when the processing of the previous external event has completed, i.e. when all microsteps (involving all triggered transitions) have been completely taken.
See {ref}`actions` for the callback execution order within each step,
{ref}`sending-events` for how to trigger events, and {ref}`behaviour`
for customizations that affect how the engine processes transitions.
(macrostep-microstep)=
The processing loop is organized into two levels:
A microstep is the smallest unit of processing. It takes a set of
enabled transitions and walks them through a fixed sequence of
callback groups defined in the {ref}execution order <actions>:
- Prepare — enrich event kwargs.
- Validators / Conditions — check if the transition is allowed.
- Before — run pre-transition callbacks.
- Exit — leave source states (innermost first).
- On — execute transition actions.
- Enter — enter target states (outermost first).
- Invoke — spawn background work.
- After — run post-transition callbacks (always runs, even on error).
If an error occurs during steps 3–6 and `catch_errors_as_events` is enabled,
the error is caught at the **block level** — remaining actions in that block
are skipped, but the microstep continues. See
{ref}`error-execution` and the
{ref}`cleanup / finalize pattern <error-handling-cleanup-finalize>`.
A macrostep is a complete processing cycle triggered by a single external event. It consists of one or more microsteps and only ends when the machine reaches a stable configuration — no eventless transitions are enabled and the internal queue is empty.
Within a single macrostep, the engine repeats:
- Check eventless transitions — transitions without an event that fire automatically when their guard conditions are met.
- Drain the internal queue — events placed by
raise_()are processed immediately, before any external events. - If neither step produced a transition, the macrostep is done.
After the macrostep completes, the engine picks the next event from the
external queue (placed by send()) and starts a new macrostep.
The engine maintains two separate FIFO queues:
| Queue | How to enqueue | When processed |
|---|---|---|
| Internal | {func}raise_() <StateMachine.raise_> or send(..., internal=True) |
Within the current macrostep |
| External | {func}send() <StateMachine.send> |
After the current macrostep ends |
This distinction matters when you trigger events from inside callbacks.
Using raise_() ensures the event is handled as part of the current
processing cycle, while send() defers it to after the machine reaches
a stable configuration.
See {ref}`sending-events` for examples of `send()` vs `raise_()`.
The following diagram shows the complete processing loop:
send("event")
│
▼
┌──────────────┐
│ External │
│ Queue │◄─────────────────────────────┐
└──────┬───────┘ │
│ pop event │
▼ │
┌──────────────────────────────────────┐ │
│ Macrostep │ │
│ │ │
│ ┌──────────────────────┐ │ │
│ │ Eventless transitions│◄──┐ │ │
│ │ enabled? │ │ │ │
│ └──────┬───────────────┘ │ │ │
│ yes │ no │ │ │
│ │ │ │ │ │
│ │ ▼ │ │ │
│ │ ┌──────────────┐ │ │ │
│ │ │ Internal │ │ │ │
│ │ │ queue empty? │ │ │ │
│ │ └──┬───────┬───┘ │ │ │
│ │ no │ yes │ │ │ │
│ │ │ │ │ │ │
│ │ │ ▼ │ │ │
│ │ │ Stable │ │ │
│ │ │ config ───┼───────┼──────┘
│ │ │ │ │
│ ▼ ▼ │ │
│ ┌──────────────┐ │ │
│ │ Microstep │────────┘ │
│ │ (execute │ │
│ │ transitions)│ │
│ └──────────────┘ │
│ │
└─────────────────────────────────────┘
(rtc-model)= (rtc model)= (non-rtc model)=
Consider a state machine where one transition triggers another via an
after callback:
>>> from statemachine import StateChart, State
>>> class ServerConnection(StateChart):
... disconnected = State(initial=True)
... connecting = State()
... connected = State(final=True)
...
... connect = disconnected.to(connecting, after="connection_succeed")
... connection_succeed = connecting.to(connected)
...
... def on_connect(self):
... return "on_connect"
...
... def on_enter_state(self, event: str, state: State, source: State):
... print(f"enter '{state.id}' from '{source.id if source else ''}' given '{event}'")
...
... def on_exit_state(self, event: str, state: State, target: State):
... print(f"exit '{state.id}' to '{target.id}' given '{event}'")
...
... def on_transition(self, event: str, source: State, target: State):
... print(f"on '{event}' from '{source.id}' to '{target.id}'")
... return "on_transition"
...
... def after_transition(self, event: str, source: State, target: State):
... print(f"after '{event}' from '{source.id}' to '{target.id}'")
... return "after_transition"When connect is sent, the after callback triggers connection_succeed.
Under the RTC model, connection_succeed is enqueued and processed only
after connect completes:
>>> sm = ServerConnection()
enter 'disconnected' from '' given '__initial__'
>>> sm.send("connect")
exit 'disconnected' to 'connecting' given 'connect'
on 'connect' from 'disconnected' to 'connecting'
enter 'connecting' from 'disconnected' given 'connect'
after 'connect' from 'disconnected' to 'connecting'
exit 'connecting' to 'connected' given 'connection_succeed'
on 'connection_succeed' from 'connecting' to 'connected'
enter 'connected' from 'connecting' given 'connection_succeed'
after 'connection_succeed' from 'connecting' to 'connected'
['on_transition', 'on_connect']Notice that connect runs all its phases (exit → on → enter → after) before
connection_succeed starts. The after callback of connect fires while
the machine is still in connecting — and only then does connection_succeed
begin its own microstep.
The `__initial__` event is a synthetic event that the engine fires during
initialization to enter the initial state. It follows the same RTC model
as any other event.
(continuous-machines)=
Some use cases require a machine that processes multiple steps automatically within a single macrostep, driven by internal events or eventless transitions rather than external calls.
Using {func}raise_() <StateMachine.raise_> inside callbacks places events
on the internal queue, so they are processed within the current macrostep.
This lets you chain multiple transitions from a single send() call:
>>> from statemachine import State, StateChart
>>> class Pipeline(StateChart):
... start = State("Start", initial=True)
... step1 = State("Step 1")
... step2 = State("Step 2")
... done = State("Done", final=True)
...
... begin = start.to(step1)
... advance_1 = step1.to(step2)
... advance_2 = step2.to(done)
...
... def on_enter_step1(self):
... print(" step 1: extract")
... self.raise_("advance_1")
...
... def on_enter_step2(self):
... print(" step 2: transform")
... self.raise_("advance_2")
...
... def on_enter_done(self):
... print(" done: load complete")
>>> sm = Pipeline()
>>> sm.send("begin")
step 1: extract
step 2: transform
done: load complete
>>> [s.id for s in sm.configuration]
['done']All three steps execute within a single macrostep — the caller receives control back only after the pipeline reaches a stable configuration.
{ref}Eventless transitions <eventless> fire automatically whenever their
guard condition is satisfied. Combined with a self-transition, this creates
a loop that keeps running within the macrostep until the condition becomes
false:
>>> from statemachine import State, StateChart
>>> class RetryMachine(StateChart):
... trying = State("Trying", initial=True)
... success = State("Success", final=True)
... failed = State("Failed", final=True)
...
... # Eventless transitions: fire automatically based on guards
... trying.to.itself(cond="can_retry")
... trying.to(failed, cond="max_retries_reached")
...
... # Event-driven transition (external input)
... succeed = trying.to(success)
...
... def __init__(self, max_retries=3):
... self.attempts = 0
... self.max_retries = max_retries
... super().__init__()
...
... def can_retry(self):
... return self.attempts < self.max_retries
...
... def max_retries_reached(self):
... return self.attempts >= self.max_retries
...
... def on_enter_trying(self):
... self.attempts += 1
... print(f" attempt {self.attempts}")
>>> sm = RetryMachine(max_retries=3)
attempt 1
attempt 2
attempt 3
>>> [s.id for s in sm.configuration]
['failed']The machine starts, enters trying (attempt 1), and the eventless
self-transition keeps firing as long as can_retry() returns True. Once
the limit is reached, the second eventless transition fires — all within a
single macrostep triggered by initialization.