Skip to content

State

Intent

Let an object behave differently as its lifecycle moves, without a growing if self.status == ... ladder in every method. Each state is an object that accepts the events legal for it, rejects the rest, and moves the context to its successor. The transitions become explicit, drawable and testable, which is what the interviewer really wants once an entity has three or more statuses.

When to use and when not to

Use it when

  • The entity has three or more statuses and the legal events differ per status: a vending machine, an order, an ATM session, a traffic light.
  • The same event means different things in different states: disable on an idle machine switches it off; mid-dispense it must refund first.
  • Illegal events must be rejected loudly, with a message naming the current state.
  • The follow-up "what if they press cancel while it is dispensing" should be answered by one method on one class.

Leave it out when

  • Two states and a boolean covers it (is_open).
  • Only a label varies, not behaviour: a status enum plus a transition table is enough.
  • The client picks the variant and it never moves by itself: that is Strategy.
  • The transitions live in a workflow engine or another service; model the table, not the classes.

Structure

Three roles: the Context that owns the data, a State base class that rejects every event, and one subclass per state that accepts its events and triggers the transition.

classDiagram
    class MachineState {
        +name: str
        +insert_money(machine: VendingMachine, amount: Money) None
        +select(machine: VendingMachine, code: str) None
        +dispense(machine: VendingMachine) Tuple~str,Money~
        +cancel(machine: VendingMachine) Money
        +disable(machine: VendingMachine) Money
        +enable(machine: VendingMachine) None
    }
    class Idle {
        +insert_money() None
        +disable() Money
    }
    class HasMoney {
        +insert_money() None
        +select() None
        +cancel() Money
    }
    class Dispensing {
        +dispense() Tuple~str,Money~
        +disable() Money
    }
    class OutOfService {
        +enable() None
    }
    class Slot {
        +product: str
        +price: Money
        +quantity: int
    }
    class VendingMachine {
        -_state: MachineState
        -_balance: Money
        -_selected: str
        -_slots: Dict~str,Slot~
        -_lock: Lock
        +transitions: List~Tuple~
        +insert_money(amount: Money) None
        +select(code: str) None
        +dispense() Tuple~str,Money~
        +cancel() Money
        +transition_to(state: MachineState) None
        +reserve(code: str) None
        +release() Tuple~str,Money~
        +refund() Money
    }
    MachineState <|-- Idle
    MachineState <|-- HasMoney
    MachineState <|-- Dispensing
    MachineState <|-- OutOfService
    VendingMachine o-- MachineState : current
    VendingMachine "1" *-- "many" Slot
    MachineState ..> VendingMachine : transition_to

MachineState rejects every event with InvalidStateError; each subclass overrides only the cells of the state-event matrix it accepts, so the matrix is readable from the class bodies. States hold no data, so a fresh Idle() per transition is correct and cheap.

The same machine as a lifecycle: every arrow is one overriding method, every missing arrow is a rejection.

stateDiagram-v2
    [*] --> Idle
    Idle --> HasMoney : insert_money
    HasMoney --> HasMoney : insert_money
    HasMoney --> Dispensing : select, once reserve succeeds
    HasMoney --> Idle : cancel, refund
    Dispensing --> Idle : dispense, product and change
    Dispensing --> OutOfService : disable, refund first
    Idle --> OutOfService : disable
    OutOfService --> Idle : enable

Canonical example in Python

The states come first (code/patterns/state.py, tested by code/patterns/tests/test_state.py):

code/patterns/state.py — the base state and the four concrete states
class MachineState:
    """Base state: every event is rejected here; a subclass overrides the ones it accepts.

    States hold no data of their own (balance, stock and the selection live on the
    machine), so a fresh instance is created at each transition and nothing needs
    resetting. Each handler receives the machine because it is the machine's data
    it works on, and it is the machine's ``transition_to`` that moves things along.
    """

    @property
    def name(self) -> str:
        return type(self).__name__

    def _reject(self, event: str) -> InvalidStateError:
        return InvalidStateError(f"cannot {event} while {self.name}")

    def insert_money(self, machine: VendingMachine, amount: Money) -> None:
        raise self._reject("insert money")

    def select(self, machine: VendingMachine, code: str) -> None:
        raise self._reject("select")

    def dispense(self, machine: VendingMachine) -> tuple[str, Money]:
        raise self._reject("dispense")

    def cancel(self, machine: VendingMachine) -> Money:
        raise self._reject("cancel")

    def disable(self, machine: VendingMachine) -> Money:
        raise self._reject("disable")

    def enable(self, machine: VendingMachine) -> None:
        raise self._reject("enable")


class Idle(MachineState):
    """Nothing inserted: accepts money, or the operator taking the machine offline."""

    def insert_money(self, machine: VendingMachine, amount: Money) -> None:
        machine.add_balance(amount)
        machine.transition_to(HasMoney())

    def disable(self, machine: VendingMachine) -> Money:
        machine.transition_to(OutOfService())
        return Money(0)


class HasMoney(MachineState):
    """Credit on the display: more coins, a selection, or a refund."""

    def insert_money(self, machine: VendingMachine, amount: Money) -> None:
        machine.add_balance(amount)  # a self-transition: the state object stays

    def select(self, machine: VendingMachine, code: str) -> None:
        machine.reserve(code)  # raises on a bad code, an empty slot or a short balance: no transition
        machine.transition_to(Dispensing())

    def cancel(self, machine: VendingMachine) -> Money:
        refund = machine.refund()
        machine.transition_to(Idle())
        return refund


class Dispensing(MachineState):
    """The motor is running: only the hardware's confirmation or a fault moves us on."""

    def dispense(self, machine: VendingMachine) -> tuple[str, Money]:
        product, change = machine.release()
        machine.transition_to(Idle())
        return product, change

    def disable(self, machine: VendingMachine) -> Money:
        refund = machine.refund()  # a jam: give the money back, then take the machine offline
        machine.transition_to(OutOfService())
        return refund


class OutOfService(MachineState):
    """Offline: accepts nothing except the operator bringing it back."""

    def enable(self, machine: VendingMachine) -> None:
        machine.transition_to(Idle())

Three decisions to say out loud:

  • The base class rejects; subclasses accept. Adding OutOfService to an if/elif design touches every event method; here it is one new class and two overrides. The rejection names the state, which is the message a test asserts.
  • Validate before you transition. HasMoney.select asks the machine to reserve the slot first; a bad code, an empty slot or a short balance raises, and the machine stays in HasMoney with its balance intact. The transition is the last statement of a handler, never the first.
  • States are stateless. Balance, stock and the selection live on the machine, so states never need resetting. Dispensing reads the selected code from the machine, which is why the machine is passed into every handler.

The context does two jobs, holding the data and serialising events:

code/patterns/state.py — the context
@dataclass(slots=True)
class Slot:
    """One product tray: the data the states operate on."""

    product: str
    price: Money
    quantity: int


class VendingMachine:
    """The Context: owns the data and the current state, and delegates every event to it.

    ``_lock`` guards ``_state``, ``_balance``, ``_selected`` and the slots. An event
    is check-and-transition inside one critical section, so two threads racing to
    ``select`` cannot both reach ``Dispensing``. The helpers below the events are
    called by states from inside an event, with the lock already held, and do not
    take it again.
    """

    def __init__(self, slots: Mapping[str, Slot]) -> None:
        self._slots = dict(slots)
        self._state: MachineState = Idle()
        self._balance = Money(0)
        self._selected: str | None = None
        self._lock = threading.Lock()
        self.transitions: list[tuple[str, str]] = []

    @property
    def state_name(self) -> str:
        with self._lock:
            return self._state.name

    @property
    def balance(self) -> Money:
        with self._lock:
            return self._balance

    def quantity(self, code: str) -> int:
        with self._lock:
            return self._slots[code].quantity

    # -- events: one delegation each, under the lock ----------------------------------
    def insert_money(self, amount: Money) -> None:
        if amount.cents <= 0:
            raise ValidationError("insert a positive amount")
        with self._lock:
            self._state.insert_money(self, amount)

    def select(self, code: str) -> None:
        with self._lock:
            self._state.select(self, code)

    def dispense(self) -> tuple[str, Money]:
        with self._lock:
            return self._state.dispense(self)

    def cancel(self) -> Money:
        with self._lock:
            return self._state.cancel(self)

    def disable(self) -> Money:
        with self._lock:
            return self._state.disable(self)

    def enable(self) -> None:
        with self._lock:
            self._state.enable(self)

    # -- called by states, lock already held -------------------------------------------
    def transition_to(self, state: MachineState) -> None:
        self.transitions.append((self._state.name, state.name))
        self._state = state

    def add_balance(self, amount: Money) -> None:
        self._balance += amount

    def reserve(self, code: str) -> None:
        slot = self._slots.get(code)
        if slot is None:
            raise NotFoundError(f"no slot {code}")
        if slot.quantity == 0:
            raise ConflictError(f"{slot.product} is sold out")
        if self._balance < slot.price:
            raise ValidationError(f"insert {slot.price - self._balance} more for {slot.product}")
        self._selected = code

    def release(self) -> tuple[str, Money]:
        if self._selected is None:
            raise InvalidStateError("nothing was selected")
        slot = self._slots[self._selected]
        slot.quantity -= 1
        change = self._balance - slot.price
        self._balance, self._selected = Money(0), None
        return slot.product, change

    def refund(self) -> Money:
        refund, self._balance, self._selected = self._balance, Money(0), None
        return refund

Each public event is one delegation under _lock, so check-and-transition is atomic: sixty-four threads racing to select produce exactly one Dispensing, which is the concurrency test. The helpers below the events run with the lock already held and must not lock again. transitions records every move as a pair for tests to assert.

Running python -m patterns.state prints:

--- happy path: coins in, a selection, the motor confirms ---
insert 1.00 USD          -> HasMoney, balance 1.00 USD
insert 1.00 USD          -> HasMoney, balance 2.00 USD
select A1 (cola 1.50)    -> Dispensing
dispense                 -> Idle, tray: cola, change 0.50 USD
--- every other event is refused by the state, not by an if-ladder in the machine ---
select A1                rejected: cannot select while Idle (still Idle)
dispense                 rejected: cannot dispense while Idle (still Idle)
insert 0.50 USD          -> HasMoney, balance 0.50 USD
select B2 (sold out)     rejected: chips is sold out (still HasMoney)
select A1 (cola 1.50)    rejected: insert 1.00 USD more for cola (still HasMoney)
cancel                   -> Idle, refunded 0.50 USD
--- maintenance: only an idle machine can be taken offline ---
disable                  -> OutOfService
insert 1.00 USD          rejected: cannot insert money while OutOfService (still OutOfService)
enable                   -> Idle
transitions: Idle -> HasMoney -> Dispensing -> Idle -> HasMoney -> Idle -> OutOfService -> Idle
--- the same lifecycle as an Enum and a transition table ---
idle + insert -> has_money
has_money + select -> dispensing
dispensing + dispense -> idle
idle + dispense -> rejected: cannot dispense while idle
has_money + select with 0.50 USD against 1.50 USD -> rejected: insert 1.00 USD more

Pythonic variant

When the per-state behaviour is a line or two, four classes are ceremony. The whole lifecycle fits in a table, and an Enum plus a table is the form most production code uses:

code/patterns/state.py — the lifecycle as an Enum and a transition table
class Status(StrEnum):
    IDLE = "idle"
    HAS_MONEY = "has_money"
    DISPENSING = "dispensing"
    OUT_OF_SERVICE = "out_of_service"


class Event(StrEnum):
    INSERT = "insert"
    SELECT = "select"
    DISPENSE = "dispense"
    CANCEL = "cancel"
    DISABLE = "disable"
    ENABLE = "enable"


# The whole lifecycle on one screen; a missing key *is* the rejection.
TRANSITIONS: Final[Mapping[tuple[Status, Event], Status]] = MappingProxyType(
    {
        (Status.IDLE, Event.INSERT): Status.HAS_MONEY,
        (Status.IDLE, Event.DISABLE): Status.OUT_OF_SERVICE,
        (Status.HAS_MONEY, Event.INSERT): Status.HAS_MONEY,
        (Status.HAS_MONEY, Event.SELECT): Status.DISPENSING,
        (Status.HAS_MONEY, Event.CANCEL): Status.IDLE,
        (Status.DISPENSING, Event.DISPENSE): Status.IDLE,
        (Status.DISPENSING, Event.DISABLE): Status.OUT_OF_SERVICE,
        (Status.OUT_OF_SERVICE, Event.ENABLE): Status.IDLE,
    }
)


def next_status(status: Status, event: Event) -> Status:
    """A pure function: the table decides, the caller keeps the data."""
    try:
        return TRANSITIONS[(status, event)]
    except KeyError:
        raise InvalidStateError(f"cannot {event} while {status}") from None


def next_status_guarded(status: Status, event: Event, *, balance: Money, price: Money) -> Status:
    """``match`` for the one transition that carries a guard; the rest still comes from the table."""
    match status, event:
        case Status.HAS_MONEY, Event.SELECT if balance < price:
            raise ValidationError(f"insert {price - balance} more")
        case _:
            return next_status(status, event)
  • The table is the specification. A reviewer reads eight lines and sees every legal move; the state diagram above is a rendering of this mapping. A missing key is the rejection, so there is no default branch to forget.
  • match when a transition has a guard. next_status_guarded keeps the table for the unguarded cells and adds the balance check to one case. Guards are where classes start earning their keep again, because a guard needs data the table does not hold.
  • The data stays with the caller. next_status is pure; a machine in this style keeps status: Status and assigns the result after its own validation. StrEnum prints and persists as the string the database column holds.
Reach for When
A boolean Two states, no illegal event worth naming
Enum + transition table Three or more states, thin behaviour, a table that must be reviewed or persisted
match on (status, event) A few guarded transitions on top of the table
One class per state Entry and exit actions, per-state data, or handlers long enough to want their own tests

Draw the state diagram first whichever form you write; it is the artefact the interviewer checks your code against.

Real-world usage

  • TCP is the textbook machine (LISTEN, SYN-SENT, ESTABLISHED, TIME-WAIT): the same segment means different things per state, and the kernel implements the table.
  • http.client.HTTPConnection tracks idle, request-started and request-sent, and raises CannotSendRequest or ResponseNotReady for out-of-order calls: the enum-plus-rejection form in the standard library.
  • concurrent.futures.Future and asyncio.Future: pending, running, cancelled, finished; set_result on a finished future raises InvalidStateError, the same error name this handbook uses.
  • Workflow engines: a persisted status column plus a transition table; django-fsm's @transition(source, target) and AWS Step Functions definitions are reviewable tables.
Looks like State How to tell them apart
Strategy The same picture, a context delegating to a swappable object, so ask who switches. With Strategy the client picks a rule up front and the rules do not know each other exist; with State the object switches itself as events arrive, and each state knows its successors.
Template Method Varies the steps of one algorithm by subclassing, fixed when the class is written. State varies the whole response to an event, at runtime, by swapping the delegate.
Memento State swaps behaviour as the lifecycle moves; Memento swaps data back to an earlier snapshot. A memento may well contain the status enum.
Chain of Responsibility One current object handles every event and switches itself; nothing is forwarded along a line.
A status field with checks if self.status == Status.IDLE inside every method is the ladder this pattern removes. The table form centralises the checks; the class form deletes them.

Where it appears in LLD problems

Interview tips

Interview tip

Draw the state diagram before the class diagram, and put the rejections on it as well as the arrows: "cancel while dispensing is refused; disable while dispensing refunds first." Then say which form you would write, "an Enum and a table, promoted to classes if a state grows entry actions or its own data", and name the test: every (state, event) pair, legal ones move, the rest raise InvalidStateError.

Common mistake

if self.status == Status.HAS_MONEY: repeated inside every method. It is the ladder with extra steps: transitions are scattered, a new state touches every method, and nobody can see which pairs are legal. Put the pairs in one place, a table or a class per state. Runner-up: transitioning before validating, so a failed selection leaves the machine in Dispensing with nothing to dispense.