Guards
Conditional transitions — control flow with boolean guard functions.
Guards are boolean functions that control whether a transition is allowed to fire. When an event arrives, the interpreter evaluates each candidate transition’s guard in order — the first transition whose guard returns True (or has no guard) wins. If every guard returns False, the event is discarded.
What are Guards?
A guard is a pure, synchronous function that answers one question: “Should this transition happen right now?” Guards inspect the machine’s context and the incoming event data to make that decision.
Key characteristics:
- Boolean — must return
TrueorFalse. - Synchronous —
async defguards raiseNotSupportedError. - Side-effect-free — guards should only read context, never modify it. Use actions for mutations.
- Short-circuiting — the first matching transition wins; remaining guards are not evaluated.
Guard Signature
def my_guard(context: dict, event: Event) -> bool:
...
Note: Guard functions receive
(context, event)— NOT(interpreter, context, event, action_def). This is intentionally different from the action signature. Guards should be pure decision functions with no need for the interpreter.
JSON Guards
The guard Key (and Legacy cond Alias)
The library accepts both "guard" and "cond" as the key for conditional transitions:
// Preferred (XState v5 style)
{"target": "allowed", "guard": "isAdult"}
// Also supported (XState v4 style)
{"target": "allowed", "cond": "isAdult"}
Both are functionally identical. The cond key exists for backward compatibility with XState v4 configs — the library normalizes it to guard internally. Prefer guard in new configs.
Single Guard
Attach a guard key to a transition object:
{
"id": "ageGate",
"initial": "checking",
"context": { "age": 21 },
"states": {
"checking": {
"on": {
"VERIFY": {
"target": "allowed",
"guard": "isAdult"
}
}
},
"allowed": {},
"rejected": {}
}
}
If isAdult returns False, the VERIFY event is silently discarded — no transition happens.
Multiple Guarded Transitions (Array Form)
Use an array of transition objects to define multiple candidate transitions for the same event. The interpreter evaluates them top-to-bottom, and the first matching guard wins:
{
"id": "ageGate",
"initial": "checking",
"context": { "age": 16 },
"states": {
"checking": {
"on": {
"VERIFY": [
{ "target": "allowed", "guard": "isAdult" },
{ "target": "teen", "guard": "isTeen" },
{ "target": "rejected" }
]
}
},
"allowed": {},
"teen": {},
"rejected": {}
}
}
Fallback Transition (No Guard)
The last transition in the array has no guard key — it acts as a fallback that always matches. This is the “else” branch:
"VERIFY": [
{ "target": "allowed", "guard": "isAdult" },
{ "target": "rejected" }
]
Tip: Always include a fallback transition as the last item. Without one, events may be silently discarded if no guard matches.
Guard Implementation with MachineLogic
When using the JSON configuration approach, implement guards as methods on a MachineLogic subclass:
from xstate_statemachine import create_machine, SyncInterpreter, MachineLogic
config = {
"id": "ageGate",
"initial": "checking",
"context": {"age": 16},
"states": {
"checking": {
"on": {
"VERIFY": [
{"target": "allowed", "guard": "isAdult"},
{"target": "teen", "guard": "isTeen"},
{"target": "rejected"}
]
}
},
"allowed": {},
"teen": {},
"rejected": {}
}
}
class AgeLogic(MachineLogic):
def isAdult(self, context, event):
return context.get("age", 0) >= 18
def isTeen(self, context, event):
return 13 <= context.get("age", 0) < 18
machine = create_machine(config, logic=AgeLogic())
interp = SyncInterpreter(machine).start()
interp.send("VERIFY")
print(interp.current_state_ids) # {"ageGate.teen"} — age is 16
interp.stop()
Pythonic Guards
@guard Decorator
The @guard decorator marks a function as a guard for any Pythonic API style:
from xstate_statemachine import guard
@guard
def is_adult(context, event):
return context.get("age", 0) >= 18
Auto-Naming (snake_case → camelCase)
The decorator automatically converts snake_case function names to camelCase for the guard registry:
@guard
def is_valid_email(context, event):
return "@" in context.get("email", "")
# Registered as "isValidEmail"
Explicit Naming with @guard("customName")
Override the auto-generated name by passing a string argument:
@guard("checkAge")
def verify_user_age(context, event):
return context.get("age", 0) >= 18
# Registered as "checkAge" (not "verifyUserAge")
Multiple Guarded Transitions with | Operator
In the class-based API, combine transitions using | to create guarded routing:
from xstate_statemachine import State, StateMachine, SyncInterpreter, guard
class AgeGate(StateMachine):
machine_id = "ageGate"
initial_context = {"age": 16}
checking = State("checking", initial=True)
allowed = State("allowed")
rejected = State("rejected")
# First matching guard wins; last transition is the fallback
verify = (
checking.to(allowed, event="VERIFY", guard="isAdult")
| checking.to(rejected, event="VERIFY")
)
@guard
def is_adult(self, context, event):
return context.get("age", 0) >= 18
machine = AgeGate.create_machine()
interp = SyncInterpreter(machine).start()
interp.send("VERIFY")
print(interp.current_state_ids) # {"ageGate.rejected"} — age is 16
interp.stop()
Guard Evaluation Order
The FIRST matching guard wins. Order matters. The interpreter evaluates candidate transitions from top to bottom (in arrays) or first to last (in | chains):
# Order matters! isVIP is checked first.
verify = (
checking.to(vip_lounge, event="ENTER", guard="isVIP")
| checking.to(allowed, event="ENTER", guard="isAdult")
| checking.to(rejected, event="ENTER") # fallback
)
If a user is both VIP and adult, they go to vip_lounge because isVIP is evaluated first.
Warning: Placing the fallback (no-guard) transition before guarded transitions means the fallback always wins and the guards are never checked. Always put fallbacks last.
Guards with Context
Guards commonly read context to make decisions:
from xstate_statemachine import create_machine, SyncInterpreter, MachineLogic
config = {
"id": "withdrawalMachine",
"initial": "idle",
"context": {"balance": 500.00},
"states": {
"idle": {
"on": {
"WITHDRAW": [
{"target": "processing", "guard": "hasSufficientFunds"},
{"target": "denied"}
]
}
},
"processing": {},
"denied": {}
}
}
class BankLogic(MachineLogic):
def hasSufficientFunds(self, context, event):
amount = event.payload.get("amount", 0)
return context["balance"] >= amount
machine = create_machine(config, logic=BankLogic())
interp = SyncInterpreter(machine).start()
interp.send("WITHDRAW", amount=200.00)
print(interp.current_state_ids) # {"withdrawalMachine.processing"}
interp.stop()
Guards with Event Data
Guards can also inspect the incoming event payload:
class RegistrationLogic(MachineLogic):
def isValidAge(self, context, event):
age = event.payload.get("age", 0)
return isinstance(age, int) and 0 < age < 150
def hasAcceptedTerms(self, context, event):
return event.payload.get("termsAccepted", False) is True
interp.send("REGISTER", age=25, termsAccepted=True)
Combining Guards
When you need to check multiple conditions, you have two approaches:
Approach 1: Single guard with compound logic
class Logic(MachineLogic):
def canPurchase(self, context, event):
has_funds = context["balance"] >= event.payload.get("price", 0)
is_in_stock = context.get("stock", 0) > 0
return has_funds and is_in_stock
Approach 2: Multiple guarded transitions (more readable)
"BUY": [
{ "target": "outOfStock", "guard": "isOutOfStock" },
{ "target": "insufficientFunds", "guard": "insufficientBalance" },
{ "target": "purchased" }
]
Tip: Use approach 1 when conditions are closely related. Use approach 2 when each failure case needs a different target state.
Guards Must Be Synchronous
Guards cannot be async def. Attempting to register an async guard raises NotSupportedError immediately:
from xstate_statemachine import guard, NotSupportedError
# This raises NotSupportedError at decoration time!
try:
@guard
async def is_valid(context, event):
return True
except NotSupportedError as e:
print(e) # "Guard 'is_valid' must be synchronous (guards cannot be async)"
This restriction exists because guard evaluation happens in the hot path of transition resolution. Async guards would introduce unpredictable timing into the state machine’s deterministic flow.
Guards vs Actions
| Guards | Actions | |
|---|---|---|
| Purpose | Control flow — decide if a transition happens | Side effects — do something during a transition |
| Return value | bool (required) |
None |
| Signature | (context, event) -> bool |
(interpreter, context, event, action_def) -> None |
| Side effects | Should have none | Expected to have side effects |
| Timing | Evaluated before the transition | Executed during the transition |
| Context mutation | Never — read only | Allowed and common |
Error Handling in Guards
If a guard raises an exception, the interpreter treats it as False — the transition is skipped and the next candidate is tried:
class Logic(MachineLogic):
def isValid(self, context, event):
# If "data" key is missing, KeyError is raised
# The interpreter catches it and treats this guard as False
return context["data"]["value"] > 0
Tip: Prefer defensive coding with
.get()to avoid relying on exception-as-False behavior. It makes guards easier to debug:
class Logic(MachineLogic):
def isValid(self, context, event):
data = context.get("data")
if data is None:
return False
return data.get("value", 0) > 0
Complete Example: Age Verification Gate
A full example with multiple paths based on age ranges:
from xstate_statemachine import create_machine, SyncInterpreter, MachineLogic
config = {
"id": "ageVerification",
"initial": "verifying",
"context": {},
"states": {
"verifying": {
"on": {
"SUBMIT_AGE": [
{"target": "senior", "guard": "isSenior"},
{"target": "adult", "guard": "isAdult"},
{"target": "teen", "guard": "isTeen"},
{"target": "child", "guard": "isChild"},
{"target": "invalid"}
]
}
},
"senior": {},
"adult": {},
"teen": {},
"child": {},
"invalid": {}
}
}
class AgeVerificationLogic(MachineLogic):
def isSenior(self, context, event):
age = event.payload.get("age", -1)
return age >= 65
def isAdult(self, context, event):
age = event.payload.get("age", -1)
return 18 <= age < 65
def isTeen(self, context, event):
age = event.payload.get("age", -1)
return 13 <= age < 18
def isChild(self, context, event):
age = event.payload.get("age", -1)
return 0 <= age < 13
machine = create_machine(config, logic=AgeVerificationLogic())
# Test with different ages
for test_age in [70, 30, 15, 8, -5]:
interp = SyncInterpreter(machine).start()
interp.send("SUBMIT_AGE", age=test_age)
state = next(iter(interp.current_state_ids)).split(".")[-1]
print(f"Age {test_age:>3} -> {state}")
interp.stop()
# Output:
# Age 70 -> senior
# Age 30 -> adult
# Age 15 -> teen
# Age 8 -> child
# Age -5 -> invalid
Complete Example: Role-Based Routing
A role-based access control machine using guards on event payload:
from xstate_statemachine import (
State, StateMachine, SyncInterpreter, guard, action
)
class RoleRouter(StateMachine):
machine_id = "roleRouter"
initial_context = {"auditLog": []}
gate = State("gate", initial=True)
admin_area = State("adminArea")
user_area = State("userArea")
guest_area = State("guestArea")
# Route based on role — first matching guard wins
access = (
gate.to(admin_area, event="ACCESS", guard="isAdmin", actions=["logAccess"])
| gate.to(user_area, event="ACCESS", guard="isUser", actions=["logAccess"])
| gate.to(guest_area, event="ACCESS", actions=["logAccess"])
)
@guard
def is_admin(self, context, event):
return event.payload.get("role") == "admin"
@guard
def is_user(self, context, event):
return event.payload.get("role") == "user"
@action
def log_access(self, interpreter, context, event, action_def):
role = event.payload.get("role", "guest")
name = event.payload.get("name", "anonymous")
context["auditLog"].append(f"{name} ({role})")
machine = RoleRouter.create_machine()
# Test admin access
interp = SyncInterpreter(machine).start()
interp.send("ACCESS", role="admin", name="Alice")
print(interp.current_state_ids) # {"roleRouter.adminArea"}
print(interp.context["auditLog"]) # ["Alice (admin)"]
interp.stop()
# Test guest access (no role specified)
interp = SyncInterpreter(machine).start()
interp.send("ACCESS", name="Bob")
print(interp.current_state_ids) # {"roleRouter.guestArea"}
print(interp.context["auditLog"]) # ["Bob (guest)"]
interp.stop()
See Also
- Context — guards often read context values to make decisions
- Actions — actions that run alongside guarded transitions
- Pythonic API —
@guarddecorator and the|operator for combining guarded transitions - Core Concepts — guard evaluation order and how guards fit into the transition lifecycle
- Troubleshooting — common guard-related errors