State pattern
Open in the interactive app: practice, quiz, mock interview →In one minute
- State lets an object change its behaviour when its internal mode changes, by delegating each operation to a state object. Each state is a small class that knows which operations are legal in that mode and which state comes next.
- It replaces the
switch (status)repeated at the top of every method. Adding a state becomes one new class instead of an edit to every switch, and illegal operations become explicit, tested errors. - Keep data on the context and behaviour on the states. States with no fields can be shared singletons, so a transition is a pointer assignment, not an allocation.
- The state object lives in memory; the truth usually lives in a database column. Persist the state name, rebuild the object on load, and make every transition a conditional update so two requests cannot both move the same record.
- For a handful of states and events, an enum with a transition table is often clearer than classes. Reach for State when each state carries real behaviour, not just a yes or no.
How it works
The problem
A publishing tool moves articles through a review workflow: an author writes a draft, submits it, two editors approve it, and the author publishes it. Editors can reject at any point before publication, which sends the article back to draft. Published articles can be archived. The first version stored a status string and checked it everywhere:
def approve(self, user):
if self.status != "review":
raise ValueError("cannot approve in " + self.status)
if user == self.author:
raise ValueError("author cannot approve")
self.approvals.add(user)
if len(self.approvals) >= 2:
self.status = "approved"Every method starts with a check like that, and each check encodes part of a state diagram that nobody can see in one place. Adding a "scheduled" state between approved and published means auditing every method. The bug that eventually shipped was predictable: a new API endpoint for bulk editing forgot the status check and let authors change the text of approved articles after review.
One class per state
State moves the per-mode behaviour into objects:
class Review(DocState):
name = "review"
def approve(self, doc, user):
if user == doc.author:
raise ValueError("author cannot approve")
doc.approvals.append(user)
if len(doc.approvals) >= doc.required:
doc.transition(APPROVED)The Document (the context) forwards every call to its current state: def approve(self, user): self.state.approve(self, user). A base class implements every operation as "cannot do this in this state", and each concrete state overrides only what it allows. Open Review and you see everything a document in review can do. The illegal combinations do not need code at all; they inherit the refusal.
Where the data lives
States should hold behaviour, not data. The text, author, approvals and history stay on the document. That has two benefits. Transitions never copy data between objects, so nothing is lost when the state changes. And states with no fields can be shared: one REVIEW instance serves every document in the process, the flyweight trick the vending machine design in this course uses as well.
Who triggers transitions
In the classic form, states decide the next state themselves (doc.transition(APPROVED)). That keeps each transition next to the rule that causes it, at the cost of states knowing about each other. The alternative is a central transition table, {("review", "reject"): "draft", ...}, consulted by the context. Tables are easy to draw, validate and generate diagrams from; classes are better when each transition has real logic (counting approvals, checking guards, sending notifications). Many production systems combine them: a table for which transitions exist, and per-state code for what happens.
State versus Strategy
The two have the same structure. The difference is who swaps the object. A strategy is chosen from outside, usually once, and does not know its siblings. A state is replaced by the states themselves as events arrive, and the set of states forms a closed graph. If your "strategies" start calling context.set_strategy(...) on each other, you have a state machine.
Persisting state machines
A document lives longer than a process. Store the state name in a column, and on load map it back to the state object: STATES[row.state]. Two editors clicking "approve" at the same moment are two processes loading the same row, so the in-memory object cannot protect you. Make each transition a conditional write, UPDATE docs SET state = 'approved', version = version + 1 WHERE id = $1 AND state = 'review' AND version = $2, and treat zero rows updated as "someone else moved it first". The state pattern decides what is legal; the database decides who wins.
Reviewing agent-written code
Asked to add a state, agents tend to add one more elif status == ... to each method they happen to open and miss the others. Asked to "use the State pattern", they sometimes put data in the states, or create a new state object on every transition. In review, check that every operation is defined for every state (the base class should refuse by default), that data stays on the context, that the state name written to storage round-trips to the same object, and that tests cover the illegal transitions as well as the happy path. A state diagram in the pull request makes missing edges obvious.
The same shape elsewhere
State is the multi-step consistency shape in miniature: a long-lived entity moves through steps, and each step allows a different set of operations. Orders (placed, paid, shipped, delivered, refunded), payment intents, Kubernetes pods (Pending, Running, Succeeded, Failed), TCP connections (LISTEN, SYN-RECEIVED, ESTABLISHED, FIN-WAIT-1 and so on), and pull requests all have the same structure and the same bugs: transitions that skip a guard, two actors racing on one record, and states added without updating every handler.
Down the stack
Layer 1The contract
A state machine promises: every operation is either valid in the current state, in which case it performs its effect and moves to exactly one next state, or invalid, in which case it fails with an error that names the operation and the state and changes nothing. Guards (who may approve, whether the text is empty) are checked before any mutation. The history of states is append-only. Across processes, the contract adds: a transition based on a stale read must fail, not overwrite a newer state. Tests should cover every (state, operation) pair, which for 5 states and 6 operations is 30 cases, most of them refusals.
Layer 2The mechanism
The Linux kernel uses the enum form for TCP. sk->sk_state holds values such as TCP_ESTABLISHED, TCP_SYN_RECV and TCP_FIN_WAIT1, and tcp_rcv_state_process switches on it to decide what an incoming segment means. The states and transitions follow the diagram in RFC 9293. The enum form suits the kernel: the set of states is fixed by a standard, and the handling for each segment type is easier to read side by side.
Go's text/template lexer, described in Rob Pike's "Lexical Scanning in Go" talk, uses state functions: type stateFn func(*lexer) stateFn. Each state is a function that does its work and returns the next state, and the run loop is for state := lexStart; state != nil; { state = state(l) }. It is the State pattern with functions instead of classes, and it has no switch at all.
Statechart libraries such as XState add what plain state machines lack: hierarchical states (a "review" superstate containing "awaiting first approval" and "awaiting second"), guards on transitions, entry and exit actions, and parallel regions. Workflow engines like AWS Step Functions and Temporal persist the current state and its history durably, so a machine can survive restarts and run for months.
Layer 3The machine
An enum state is one byte or one machine word. A switch on a dense enum compiles to a bounds check and a jump table: two or three instructions and one indirect jump. A state object is one pointer (8 bytes) on the context, and each operation is an indirect call through the object's vtable (C++, Java), itab (Go) or prototype chain lookup (JavaScript). Predicted, that costs 1 to 2 ns; mispredicted, 15 to 20 cycles. At the rates a workflow sees (a few transitions per document per day) neither number matters. It matters in parsers and protocol stacks that run a transition per byte or per packet, which is why lexers and TCP use tight loops over enums or functions.
Allocation is the other cost. If each transition creates a new state object, a busy service allocates on every event and the garbage collector pays for it. Stateless states shared as singletons remove that cost entirely. In V8, a call site like this.state.approve(...) sees a different hidden class per state. With five states it becomes megamorphic (more than four shapes), and V8 falls back to a slower generic property lookup. Using one class with a function table, or keeping hot paths to a few states, keeps the call site fast.
Concurrency belongs at the boundary. Within one process, guard the context with a mutex or confine it to one goroutine or event loop, so a transition's guard check and its write cannot interleave with another. Across processes, the database row is the lock: the conditional UPDATE above is a compare-and-set, and on Postgres it takes a row lock only for the duration of the statement.
Why it's built this way
- Long-lived entities move through modes, and each mode allows different operations, so the rules form a graph.
- Encoding that graph as checks scattered across methods hides it, and new states miss checks, so each mode becomes one class that holds its rules.
- Refusal is the common case, so a base class refuses by default and states override only what they allow.
- States are behaviour and data lives on the context, so states can be shared singletons and transitions are pointer swaps.
- The process is not the source of truth, so the state name is persisted and transitions become conditional writes.
Diagram
stateDiagram-v2 [*] --> draft draft --> draft: edit (author) draft --> review: submit (author, text not empty) review --> review: approve (below required) review --> approved: approve (reaches required) review --> draft: reject (clears approvals) approved --> draft: reject (clears approvals) approved --> published: publish (author) published --> archived: archive archived --> [*]
Worked example
A newsroom publishes about 600 articles a day; each passes through 9 statuses on average, so the system handles roughly 5,400 transitions a day plus about 40,000 edits. The original code had a status column and 37 places that read it, across the web app, a mobile API and two background jobs. An audit found 4 of those places allowed edits in a state where edits should have been blocked. In one incident a correction was applied to an article after legal review, and the legally reviewed version was never the one readers saw.
The team moved the rules into one module with a state class per status and a base class that refuses everything. Every entry point now loads the article, calls the context method, and saves with a conditional update on (state, version). The test suite enumerates all 12 statuses against all 9 operations, 108 cases, generated from a table. In the first month, conditional updates rejected about 30 transitions a day, almost all of them double clicks and two editors approving at once; before, those had silently overwritten each other. Adding the "scheduled" status later touched one new class, two transitions and the table. The per-operation overhead of the state lookup is under a microsecond against a 4 ms database write, so the refactor cost nothing measurable at runtime.
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
Status field and if checks | Least code at first | Rules scattered; new states miss checks | Two or three states with trivial rules |
| Enum plus transition table | Whole graph in one place; easy to validate and draw | Behaviour per state ends up in switches elsewhere | Many states, simple transitions, generated diagrams |
| State pattern (class per state) | Behaviour and rules for a mode in one place; refusals by default | More types; states know their successors | Each state has real behaviour and guards |
| State functions | No classes, no switch; very fast | Harder to persist or inspect | Lexers, parsers, protocol loops |
| Statechart library or workflow engine | Hierarchy, guards, durability, visual tools | Dependency and learning curve | Complex or long-running workflows |
Pitfalls in production
- Data stored in state objects, so a transition loses or duplicates it. Fix: keep all data on the context; states hold only behaviour.
- A new state that some operations do not handle. Fix: a base class that refuses every operation by default, plus a test that runs every operation in every state.
- Two requests racing on the same record, both reading "review" and both transitioning. Fix: a conditional update on state and version, and an error when no row changed.
- Guards checked after mutation, so a failed operation leaves partial changes. Fix: validate first, then mutate, then transition.
- Persisted state names that change with a refactor, so old rows no longer load. Fix: treat stored names as a schema; migrate them explicitly.
- Side effects (emails, webhooks) fired inside the transition before the database commit, so a rolled-back transition still notified people. Fix: emit events after commit, or through an outbox.
Leadership lens
- Ask for the state diagram in the design review. If nobody can draw it, the code has one anyway, scattered across handlers.
- Make illegal transitions a product decision, not an accident. Product and legal often care more about what must not happen (editing after approval) than about the happy path.
- Treat the state column as an API shared with analytics, support tools and other services. Renaming or splitting a state is a migration with consumers to notify.
- Require concurrency handling for every persisted state machine: which writes are conditional, and what the user sees when they lose a race.
- Give agents the transition table and the refusal tests first. They then add a state by adding a class and table rows, and the tests tell them what they missed.
Check yourself
Why should state objects avoid holding data such as the approval list?
Show answer
Data in state objects must be copied on every transition, and a missed copy loses it. With data on the context, states are stateless, can be shared, and transitions are simple pointer swaps.
Two editors approve the same document at the same moment from different servers. What prevents a lost update?
Show answer
A conditional database write on the expected state and version. One update succeeds; the other affects zero rows and must reload and retry or report the conflict.
When is an enum with a transition table better than one class per state?
Show answer
When transitions are simple and you value seeing the whole graph in one place, such as a fixed protocol, or when you want to generate diagrams and exhaustive tests from the table.
How do you tell whether code you are reading is Strategy or State?
Show answer
Look at who swaps the object. If the objects replace each other in response to events, it is State; if the choice comes from outside and stays fixed, it is Strategy.
Watch
- State Pattern, Design Patterns ep 17 (Christopher Okhravi): builds the pattern from a switch-based design and compares it with Strategy.
- Lexical Scanning in Go (Rob Pike): state functions in Go's template lexer, a fast state machine with no classes and no switch.
Free resources
- State (Game Programming Patterns, Robert Nystrom): from flags to finite state machines, hierarchical states and pushdown automata, with honest trade-offs.
- State pattern (Refactoring.Guru): the structure and its relation to Strategy, with code in many languages.
- RFC 9293: Transmission Control Protocol: the TCP connection state machine, a standard every kernel implements.
- Lexical Scanning in Go (slides): the
stateFntechnique in a few slides. - XState documentation (Stately): statecharts with guards, hierarchy and actions for JavaScript and TypeScript.
- Finite-state machine (Wikipedia): background on states, transitions and transition tables.