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"). Because StrEnum instances are strings, you can use them directly as dict keys, in comparisons, and as arguments; no .value unwrapping 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:

FSM state diagram for GitHub PR workflow

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:

  1. 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
  1. 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
  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.

  2. 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.

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.