This guide covers all backward-incompatible changes in python-statemachine 3.0 and provides step-by-step migration instructions from the 2.x series.
Most 2.x code continues to work unchanged — the `StateMachine` class preserves backward-compatible
defaults. Review this guide to understand what changed and adopt the new APIs at your own pace.
**Using an AI coding assistant?** You can use this guide as context for automated migration.
Try a prompt like:
> Update my usage of python-statemachine following this upgrade guide:
> https://python-statemachine.readthedocs.io/en/latest/releases/upgrade_2x_to_3.html
>
> Apply only the changes that are relevant to my codebase. Do not change working behavior.
- Upgrade Python to 3.9+ (3.7 and 3.8 are no longer supported).
- Replace
rtc=True/Falsein constructors — the non-RTC model has been removed. - Replace
allow_event_without_transitioninit parameter with a class-level attribute. - Replace
sm.current_statewithsm.configuration/sm.configuration_values. - Replace
sm.current_state.finalwithsm.is_terminated. - Replace
sm.add_observer(...)withsm.add_listener(...). - Update code that catches
TransitionNotAllowedand accesses.state→ use.configuration. - Review
oncallbacks that queryis_activeorcurrent_stateduring transitions. - If using
StateChart, note that self-transitions now trigger entry/exit callbacks. - If using
States.from_enum, note thatuse_enum_instancenow defaults toTrue. - If using
get_machine_cls()with short names, switch to fully-qualified names. - Remove
strict_states=True/False— replace withvalidate_trap_states/validate_final_reachability. - Update code that parses
__repr__output — format changed toconfiguration=[...].
Support for Python 3.7 and 3.8 has been dropped. If you need these versions, stay on the 2.x series.
StateMachine 3.0 supports Python 3.9, 3.10, 3.11, 3.12, 3.13, and 3.14.
Version 3.0 introduces StateChart as the new base class. The existing StateMachine class is
now a subclass of StateChart with defaults that preserve 2.x behavior:
| Attribute | StateChart |
StateMachine |
|---|---|---|
allow_event_without_transition |
True |
False |
enable_self_transition_entries |
True |
False |
atomic_configuration_update |
False |
True |
catch_errors_as_events |
True |
False |
Recommendation: Use StateChart for new code. It follows the
SCXML specification defaults — structured error handling,
self-transition entry/exit, and non-atomic configuration updates.
For existing code, you can continue using StateMachine — it works as before. You can also adopt
individual StateChart behaviors granularly by overriding class-level attributes:
Before (2.x):
class MyMachine(StateMachine):
...After (3.0) — gradual adoption:
# Adopt SCXML error handling without switching to StateChart
class MyMachine(StateMachine):
catch_errors_as_events = True
# ... rest of your definition unchangedSee {ref}behaviour for full details on each attribute.
The rtc parameter was deprecated in 2.3.2 and has been removed. All events are now queued
before processing (Run-to-Completion semantics). See {ref}rtc-model.
Before (2.x):
sm = MyMachine(rtc=False) # synchronous, non-queued processingAfter (3.0):
sm = MyMachine() # RTC is always enabled, remove the parameterIf you were passing rtc=True (the default), simply remove the parameter.
This was previously an __init__ parameter and is now a class-level attribute.
Before (2.x):
sm = MyMachine(allow_event_without_transition=True)After (3.0):
class MyMachine(StateMachine):
allow_event_without_transition = True
# ... states and transitions`StateMachine` defaults to `False` (same as 2.x). `StateChart` defaults to `True`.
Due to compound and parallel states, the state machine can now have multiple active states. The
current_state property is deprecated in favor of configuration, which always returns an
OrderedSet[State]. See {ref}querying-configuration.
Before (2.x):
state = sm.current_state # returns a single State
value = sm.current_state.value # get the valueAfter (3.0):
states = sm.configuration # returns OrderedSet[State]
values = sm.configuration_values # returns OrderedSet of values
# If you know you have a single active state (flat machine):
state = next(iter(sm.configuration)) # get the single StateFor flat state machines (no compound/parallel states), `current_state_value` still returns a
single value and works as before. But we strongly recommend using `configuration` /
`configuration_values` for forward compatibility.
The old current_state.final pattern still works for flat state machines, but is_terminated
is the recommended replacement — it works correctly for all topologies (flat, compound, and
parallel), where "terminated" means all regions have reached a final state.
See {ref}checking-termination.
Before (2.x):
if sm.current_state.final:
print("done")
while not sm.current_state.final:
sm.send("next")After (3.0):
if sm.is_terminated:
print("done")
while not sm.is_terminated:
sm.send("next")The method add_observer has been removed in v3.0. Use add_listener instead.
For new code, consider using class-level listener declarations — they attach listeners
automatically to every instance and support a setup() protocol for dependency injection.
See {ref}listeners.
Before (2.x):
sm.add_observer(my_listener)After (3.0) — runtime attachment:
sm.add_listener(my_listener)After (3.0) — class-level declaration (recommended for new code):
class MyMachine(StateChart):
listeners = [MyListener]
# ... states and transitionsTransitionNotAllowed is raised when an event has no valid transition from the current
configuration. Note that this exception only applies when allow_event_without_transition
is False (the StateMachine default). In StateChart, events without matching
transitions are discarded — this follows the SCXML recommendation, where statecharts
are reactive systems and not every event is expected to be handled in every state.
The exception now stores a configuration attribute (a set of states) instead of a single
state attribute, and the event attribute can be None.
Before (2.x):
try:
sm.send("go")
except TransitionNotAllowed as e:
print(e.event) # Event instance
print(e.state) # single StateAfter (3.0):
try:
sm.send("go")
except TransitionNotAllowed as e:
print(e.event) # Event instance or None
print(e.configuration) # MutableSet[State]If you are migrating to `StateChart`, consider handling errors as events instead of
catching exceptions. With `catch_errors_as_events=True` (the default in `StateChart`),
runtime errors are dispatched as `error.execution` events that you can handle with
transitions. See {ref}`error-execution`.
This is the most impactful behavioral change for existing code. See {ref}behaviour for
full details on atomic_configuration_update.
In 2.x, the active state was updated atomically after the transition on callbacks,
meaning sm.current_state and state.is_active reflected the source state during on
callbacks.
In 3.0 (SCXML-compliant behavior in StateChart), states are exited before on callbacks
and entered after, so during on callbacks the configuration may be empty.
If you use `StateMachine` (not `StateChart`), the default `atomic_configuration_update=True`
**preserves the 2.x behavior**. This section only affects code using `StateChart` or
`StateMachine` with `atomic_configuration_update=False`.
Before (2.x):
def on_validate(self):
if self.accepted.is_active: # True during on callback in 2.x
return "congrats!"After (3.0):
Two new keyword arguments are available in on callbacks to inspect the transition context:
def on_validate(self, previous_configuration, new_configuration):
if self.accepted in previous_configuration:
return "congrats!"previous_configuration: the set of states that were active before the microstep.new_configuration: the set of states that will be active after the microstep.
To restore the old behavior globally, set the class attribute:
class MyChart(StateChart):
atomic_configuration_update = True # restore 2.x behaviorOr simply use StateMachine, which has atomic_configuration_update=True by default.
In StateChart, self-transitions (a state transitioning to itself) now execute entry and exit
actions, following the SCXML spec. In StateMachine, the 2.x behavior is preserved (no
entry/exit on self-transitions). See {ref}self-transition.
Before (2.x):
# Self-transitions did NOT trigger on_enter_*/on_exit_* callbacks
loop = s1.to.itself()After (3.0 with StateChart):
# Self-transitions DO trigger on_enter_*/on_exit_* callbacks
loop = s1.to.itself()
# To disable (preserve 2.x behavior):
class MyChart(StateChart):
enable_self_transition_entries = FalseIn 2.x, States.from_enum defaulted to use_enum_instance=False, meaning state values were the
raw enum values (e.g., integers). In 3.0, the default is True, so state values are the enum
instances themselves. See {ref}states from enum types.
Before (2.x):
states = States.from_enum(MyEnum, initial=MyEnum.start)
# states.start.value == 1 (raw value)After (3.0):
states = States.from_enum(MyEnum, initial=MyEnum.start)
# states.start.value == MyEnum.start (enum instance)If your code relies on raw enum values, pass use_enum_instance=False explicitly.
In 2.x, state machine classes were registered both by their fully-qualified name and their short class name. The short-name lookup was deprecated since v0.8 and has been removed in 3.0.
Before (2.x):
from statemachine.registry import get_machine_cls
cls = get_machine_cls("MyMachine") # short name — worked with warningAfter (3.0):
from statemachine.registry import get_machine_cls
cls = get_machine_cls("myapp.machines.MyMachine") # fully-qualified nameThe strict_states class parameter has been removed. The two validations it controlled are now
always-on by default, each controlled by its own class-level attribute.
See {ref}validations.
Before (2.x) — s2 is a trap state (no outgoing transitions, not marked final):
class MyMachine(StateMachine, strict_states=False):
s1 = State(initial=True)
s2 = State() # trap state — no outgoing transitions, not final
go = s1.to(s2)After (3.0) — recommended: fix the definition by marking terminal states as final:
class MyMachine(StateMachine):
s1 = State(initial=True)
s2 = State(final=True) # was State() — now correctly marked as final
go = s1.to(s2)After (3.0) — opt out if you intentionally have non-final trap states:
class MyMachine(StateMachine):
validate_trap_states = False # allow non-final states without outgoing transitions
validate_final_reachability = False # allow non-final states without path to final
s1 = State(initial=True)
s2 = State()
go = s1.to(s2)The two flags are independent — you can disable one while keeping the other enabled.
The send() method has new optional parameters for delayed events and internal events.
Existing code calling sm.send("event") works unchanged. See {ref}sending-events.
Before (2.x):
sm.send("event_name", *args, **kwargs)After (3.0) — fully backward compatible:
sm.send("event_name", *args, delay=0, send_id=None, internal=False, **kwargs)delay: Time in milliseconds before the event is processed.send_id: Identifier for the event, used to cancel delayed events withsm.cancel_event(send_id).internal: IfTrue, the event is placed in the internal queue (processed in the current macrostep).
The string representation now shows configuration=[...] instead of current_state=....
Before (2.x):
MyMachine(model=Model(), state_field='state', current_state='initial')
After (3.0):
MyMachine(model=Model(), state_field='state', configuration=['initial'])
The package now exports two additional symbols:
Before (2.x):
from statemachine import StateMachine, State, EventAfter (3.0):
from statemachine import StateChart # new base class
from statemachine import HistoryState # history pseudo-state for compound states
from statemachine import StateMachine # unchanged
from statemachine import State # unchanged
from statemachine import Event # unchangedFor full details on all new features in 3.0 — including compound states, parallel states,
invoke, error handling, and more — see the
{ref}3.0.0 release notes <StateMachine 3.0.0>.