After having worked with the transitions library for a while, Bob wondered how Finite State Machines work under the hood. This article shows you how he built one from scratch, modelling GitHub pull requests.
Not sure what's worth building next? That's the first thing I work through with the developers I coach. Let's figure it out →
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.
Let's model a GitHub pull request (PR) workflow, something every developer knows. Here's the state diagram:

PRs move through states with rules:
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:
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
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 the InvalidTransition exception 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.
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.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Python in a Sandbox With MicroPython and WASM | 0 | 7.16 | 28-06-2026 |
| 2 | A Primer on Database States | 0 | 5 | 14-07-2026 |
| 3 | build-your-own-openclaw - A step-by-step guide to build your own AI agent. | 0 | 28.18 | 02-06-2026 |
| 4 | Объяснение лямбда исчисления через Python | 0 | 7.94 | 22-04-2026 |
| 5 | Firestore Data Modeling Guide: Embedded Documents vs Referencing (with a Blog Case Study) | 0 | 8.19 | 24-07-2026 |
| 6 | Recursive structural pattern matching | 0 | 6.91 | 11-01-2026 |
| 7 | How to Diagnose Production Bugs When You Can't Reproduce Them Locally | 0 | 7.66 | 24-07-2026 |
| 8 | Argo CD vs Portainer GitOps: An Implementation-Level Comparison | 0 | 7 | 06-01-2026 |
| 9 | A practical guide to React error monitoring | 0 | 7.06 | 29-07-2026 |
| 10 | fapost/support | 0 | 8.26 | 03-08-2026 |