Valid transitions — and making the invalid ones impossible
coreintermediateThe roadmap draws two machines: a payment goes `pending → processing → succeeded`, or `→ failed`; an order goes `draft → placed → processing → fulfilled`, or `→ cancelled`. What makes that a state machine rather than a list of words is the set of transitions that are **not** drawn — `succeeded → pending` is absent, and so is `fulfilled → draft`. A `status` field with `choices` enforces the legal *values* and says nothing about legal *moves*, so the transition table has to be written down and checked somewhere.
Think of it as
A status field is usually introduced as a label and then quietly becomes the most important invariant in the system, because money, emails and shipments all hang off it. The shift worth making is to stop thinking about which values exist and start thinking about which *edges* exist. Once the edges are explicit, three useful things follow. First, illegal moves become detectable: `refunded → succeeded` is not a bug you have to reason about, it is an edge that is not in the table. Second, the shape of the workflow becomes reviewable — you can look at the map and ask whether a cancelled order should really be able to become placed again, which is a product question that otherwise gets decided by whichever view happened to be written first. Third, the terminal states become visible, and terminal states are what keep a workflow from cycling forever. Where to enforce it matters as much as writing it down. `choices` is validated by forms and `full_clean()`, not by the database, so it cannot stop a `QuerySet.update()` or a data migration writing nonsense — a `CheckConstraint` can, and belongs there for the value set. The transition rule itself is application logic, and it belongs in one method on the model rather than scattered across views, tasks and admin actions, because a rule with five copies has five chances to disagree. The single most valuable habit is to make the transition the only way the field changes: no `order.status = "cancelled"` anywhere except inside `transition_to`. And when a transition is rejected, distinguish two cases in how you respond. A move to the state the object is *already* in is usually a duplicate request — a double-clicked button, a retried webhook — and should be a quiet success, not an error. A move to a genuinely unreachable state is a real conflict and deserves a 409, because it means the caller's idea of the world is out of date.
What we're doing: Put the transition table on the model, enforce the value set in the database, and make one method the only path that writes the field.
- 15–21
- The table is the specification. Reading it answers product questions — can a cancelled order be placed again? — that would otherwise be settled implicitly by whichever view was written first.
- 27–30
- `choices` alone is validated by forms and `full_clean()`, so nothing stops `update(status="shipped")` or a migration writing a typo. The check constraint is the database's copy of that rule.
- 38–41
- Re-entering the current state returns `False` rather than raising. Duplicate requests are the normal case with retried tasks and impatient users, and treating them as errors produces alerts about a system working correctly.
- 43–46
- A genuinely illegal move raises with both states named. "Invalid transition" with no values is the error message you will be reading in six months.
- 48–49
- `update_fields` keeps the write narrow, which matters when the next concept adds locking — a narrow update holds its row lock for less time.
Why this works: Illegal moves raise with a readable message, duplicate requests succeed quietly, the database rejects values no code path should produce, and the workflow is legible from one table.
Assigning the status field directly
Wrong
Better
What you see: Orders appear in states the workflow diagram says are unreachable — cancelled orders being fulfilled, refunded payments back in `processing` — and no single commit looks wrong.
Why: A transition rule enforced in one method and bypassed everywhere else is not enforced. Direct assignment is easy to write, invisible in review (a two-line change in a view), and each instance individually looks reasonable — the damage comes from their combination across code paths written months apart. Making `transition_to()` the only writer means the rule lives in one place, and a grep for `\.status =` becomes a genuine audit. It also gives every future requirement — an event, an audit row, a notification — one place to hang from.
- draft (start)
- → placed when customer confirms
- → cancelled when abandoned
- placed
- → processing when payment succeeded
- → cancelled when payment failed, or cancelled
- processing
- → fulfilled when shipped
- → cancelled when cancelled before dispatch
- fulfilled (end)
- cancelled (end)
The roadmap's two machines, written as a transition table
Together
Where each rule can actually be enforced
Together
Remember: A `status` field with `choices` constrains values, never moves — the machine is the set of *edges*, and the edges that are absent (`fulfilled → draft`) are the ones doing the work. Write the table as data on the model, add a `CheckConstraint` so the database enforces the value set that `choices` only validates in forms, and make `transition_to()` the single place the field is ever assigned so a grep is a real audit. Re-entering the current state is a duplicate request and should succeed quietly; a genuinely unreachable move is a 409.
See also: concurrency and transaction boundaries · idempotency events and history · display and migrations

