State machines
A state machine (a finite automaton) models something that is always in exactly one of a fixed set of states, and moves between them only via defined transitions triggered by events. An order is pending, then paid, then shipped; a job is queued, running, then done or failed. The point is that *most* state changes are illegal — you can't ship an unpaid order — and the machine makes those rules explicit instead of scattering them through if checks.
The clean Python shape is a transition table: a dict mapping (current_state, event) to the next state. To process an event you look up (self.state, event); if it's in the table, move to the new state; if it's not, the transition is invalid — reject it (return False, or raise) and leave the state untouched. That single lookup *is* your event dispatch and your validation at once.
Keeping the current state in one attribute (and optionally a history list) means there's a single source of truth for 'where are we?'. Add a can_trigger(event) check (is this event in the table for our state?) and an is_terminal() check (are we in an end state nothing leaves?) and you've covered the questions every consumer asks.
Use an enum.Enum for the states rather than bare strings. The enum makes the legal states a closed, named set — typos become AttributeError at the point of the mistake instead of a silent invalid state slipping through.
Syntax
from enum import Enum
class State(Enum):
PENDING = "pending"
RUNNING = "running"
DONE = "done"
TRANSITIONS = { # (state, event) -> next_state
(State.PENDING, "start"): State.RUNNING,
(State.RUNNING, "complete"): State.DONE,
}
key = (current_state, event)
if key in TRANSITIONS: # valid? dispatch + validate in one
current_state = TRANSITIONS[key]Worked examples
A job state machine with an enum and a transition table
from enum import Enum
class State(Enum):
PENDING = "pending"
RUNNING = "running"
DONE = "done"
FAILED = "failed"
class Job:
TRANSITIONS = {
(State.PENDING, "start"): State.RUNNING,
(State.RUNNING, "complete"): State.DONE,
(State.RUNNING, "fail"): State.FAILED,
}
def __init__(self):
self.state = State.PENDING
self.history = [self.state]
def trigger(self, event):
key = (self.state, event)
if key not in self.TRANSITIONS:
return False # invalid — reject, don't move
self.state = self.TRANSITIONS[key]
self.history.append(self.state)
return True
job = Job()
print(job.trigger("start")) # True
print(job.trigger("complete")) # True
print(job.state.value) # done
# Output:
# True
# True
# doneInvalid transitions are rejected without changing state
from enum import Enum
class State(Enum):
PENDING = "pending"
RUNNING = "running"
TRANSITIONS = {(State.PENDING, "start"): State.RUNNING}
state = State.PENDING
key = (state, "complete") # not a legal event from PENDING
if key in TRANSITIONS:
state = TRANSITIONS[key]
else:
print("rejected:", state.value) # state unchanged
# Output:
# rejected: pendingTerminal-state and can-trigger checks
from enum import Enum
class State(Enum):
PENDING = "pending"; RUNNING = "running"
DONE = "done"; FAILED = "failed"
TRANSITIONS = {
(State.PENDING, "start"): State.RUNNING,
(State.RUNNING, "complete"): State.DONE,
}
TERMINAL = {State.DONE, State.FAILED}
state = State.RUNNING
print((state, "complete") in TRANSITIONS) # True (can trigger)
print(state in TERMINAL) # False (not an end state)
# Output:
# True
# FalseCommon mistakes & gotchas
Tuple keys must match exactly — state AND event
The dict lookup (self.state, event) only hits if *both* parts match a key. Mixing string states with enum states ("running" vs State.RUNNING) means the tuple never matches and every transition is rejected. Pick one representation for states and use it consistently everywhere.
Mutating state before validating the transition
Update self.state only *after* confirming the transition is legal. If you assign first and check later, an invalid event can leave the machine in a bad state. The guard (if key not in TRANSITIONS: return False) must come before the assignment.
Bare strings let invalid states slip in silently
With string states, a typo like self.state = "runnig" is a perfectly valid string — nothing complains, and the machine is now stuck in a state no transition matches. An enum.Enum turns that typo into an immediate AttributeError, catching it at the source.
Why it matters
Tangled boolean flags — `is_started`, `is_done`, `has_failed` — are a notorious source of impossible states and 'how did it get here?' bugs. A state machine replaces them with one explicit, validated current-state and a table of what's allowed, which is why order processing, CI pipelines, protocol parsers, and game logic are all modelled this way. It turns 'this should never happen' from a comment into a guarantee.