Build a Finite State Machine in Python
Libraries like transitions make using finite state machines (FSMs) easy. Maybe too easy. You wire up states, transitions, and callbacks, and it 'just' works. But when you need to customize behavior, debug a stuck workflow, or explain to a teammate why a transition silently failed, you need a deeper understanding. And what better way than building a simple FSM from scratch?
I've used the transitions library in production for years to manage content workflows: draft to review to approved to published, with callbacks that assign reviewers and send notifications. It looks something like this:
from enum import StrEnum, auto
from transitions import Machine
class States(StrEnum):
DRAFT = auto()
REVIEW = auto()
APPROVED = auto()
PUBLISHED = auto()
TRANSITIONS = [
{"trigger": "submit", "source": States.DRAFT, "dest": States.REVIEW, "after": "assign_reviewer"},
{"trigger": "approve", "source": States.REVIEW, "dest": States.APPROVED, "after": "notify_author"},
{"trigger": "publish", "source": States.APPROVED, "dest": States.PUBLISHED, "after": "clear_tasks"},
]
Machine(model=model, states=list(States), transitions=TRANSITIONS, initial=States.DRAFT)
StrEnum+auto()gives each member a value equal to its lowercased name (States.DRAFT == "draft"). BecauseStrEnuminstances are strings, you can use them directly as dict keys, in comparisons, and as arguments; no.valueunwrapping needed.
Powerful. But what's actually happening when you call model.submit()? How does it validate transitions, run guards, fire callbacks? Building one from scratch answers those questions and makes you better at using (and extending) the library.
Build a simple FSM from scratch
Let's model a GitHub pull request (PR) workflow, something every developer knows. Here's the state diagram:

PRs move through states with rules:
- You can't merge without approval,
- Closed PRs can reopen,
- The merged state is terminal.
Start with the transition map. This is the core data structure: a dictionary where each state maps to its set of valid next states.
TRANSITIONS = {
"draft": {"open", "closed"},
"open": {"changes_requested", "approved", "closed"},
"changes_requested": {"open", "closed"},
"approved": {"merged", "open", "closed"},
"merged": set(), # terminal
"closed": {"open"},
}
An empty set means terminal, done. This makes it easy to check, relying on Python's truthiness: if not transitions[state]: means "this state has no valid next states."
Next, let's build the StateMachine class. I often say: start with functions, but FSMs bundle state and behavior in a way that makes classes the right fit. sm.current, sm.transition("open"), sm.can_transition("merged"): a clean interface that only a class gives you.
The transition map is passed in rather than hardcoded. This keeps the class reusable for any workflow and makes the dependency explicit.
class InvalidTransition(Exception):
pass
class StateMachine:
def __init__(self, initial, transitions: dict[str, set[str]], *, guards=None, hooks=None, context=None):
self._state = initial
self._transitions = transitions
self._guards = guards if guards is not None else []
self._hooks = hooks if hooks is not None else {}
self._context = context if context is not None else {}
self._history: list[tuple[str, str]] = []
@property
def current(self):
return self._state
@property
def context(self):
return self._context
@property
def history(self):
return list(self._history)
def can_transition(self, to):
return to in self._transitions.get(self._state, set())
def transition(self, to):
if not self.can_transition(to):
raise InvalidTransition(f"Cannot go from {self._state} to {to}")
errors = []
for guard in self._guards:
if not guard(self, to):
errors.append(guard.__name__)
if errors:
raise InvalidTransition(
f"Cannot go from {self._state} to {to} "
f"due to failed guards: {', '.join(errors)}"
)
previous = self._state
self._state = to
self._history.append((previous, to))
for hook in self._hooks.get(to, []):
hook(self, previous, to)
Less than 50 lines. Let's break down the four extension points:
- Guards are functions that say "not yet." They receive the machine and target state, returning a boolean. All must pass. For example, a PR can't be merged without approvals:
def require_approval(sm, to):
return to != "merged" or sm.context.get("approvals", 0) >= 1
sm = StateMachine("draft", TRANSITIONS, guards=[require_approval], context={"approvals": 0})
sm.transition("open")
sm.transition("approved")
sm.transition("merged") # raises InvalidTransition: failed guard
- Hooks fire after a successful transition. They're callbacks keyed by destination state:
def notify_reviewers(sm, from_state, to_state):
sm.context["notifications"] = sm.context.get("notifications", 0) + 1
sm = StateMachine("draft", TRANSITIONS, hooks={"open": [notify_reviewers]})
sm.transition("open")
assert sm.context["notifications"] == 1
-
Context is a dict guards and hooks can read and write — approval counts, assigned reviewers, timestamps. Not needed in this example, but in production it's where you track everything that doesn't belong in state itself.
-
History is an append-only audit trail. Every successful transition records
(from_state, to_state). Failed guard names surface in theInvalidTransitionexception message — no instance state needed, no risk of stale data between calls.
The full implementation with tests is available as a GitHub Gist, a single file you can run with uvx pytest fsm.py -v.
The functional alternative
If you don't need guards, hooks, or history, Python's match/case can offer a simpler state dispatcher:
def next_state(current: str, action: str) -> str:
match (current, action):
case ("draft", "open"): return "open"
case ("open", "approve"): return "approved"
case ("approved", "merge"): return "merged"
case _: raise ValueError(f"Invalid: {action} from {current}")
Clean and readable for 3-4 states. But it doesn't scale. Add guards, hooks, and history, and it gets messy fast. That's when a class-based FSM is the better choice.
FSMs enforce business logic around valid system transitions. No more "how did this PR end up merged without approval?"
A simple implementation can be a class of less than 100 lines of code, and it makes you understand the patterns behind FSMs: the transition map, guards, hooks, context, and history. If you want to use another level of abstraction, the transitions library is great.
The next time you see a workflow with states and rules, implement your own FSM.
Need a second pair of eyes on your code, or not sure what to build next? Here is how I coach developers →
