Engineering Atlas › Low-Level Design

Modelling fixed choices with enums

14 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

Strings everywhere

An order service stored status as a free-form string. Two years later the database contains paid, Paid, canceled, cancelled and cancel, because six services wrote it with their own spellings. The fulfilment worker checks status == "paid" and skips every Paid order.

An enum gives the set a single definition:

class OrderStatus(Enum):
    PENDING = "pending"
    PAID = "paid"
    SHIPPED = "shipped"
    DELIVERED = "delivered"
    CANCELLED = "cancelled"
    REFUNDED = "refunded"

Inside the program there is now exactly one PAID, and a typo like OrderStatus.PIAD fails at the point of use. Strings still arrive at the edges (HTTP bodies, queue messages, old rows), so one parse_status function turns them into members: trim, lowercase, map known legacy spellings such as canceled, and reject everything else with an error that names the bad value. Everything past the edge handles members, never raw strings.

Transitions as a table

Order statuses are not just labels; they move. Pending orders can be paid or cancelled, paid orders can ship, be cancelled or be refunded, and delivered orders can be refunded. Encode that as data, one entry per member:

TRANSITIONS = {
    PENDING:   [PAID, CANCELLED],
    PAID:      [SHIPPED, CANCELLED, REFUNDED],
    SHIPPED:   [DELIVERED],
    DELIVERED: [REFUNDED],
    CANCELLED: [],
    REFUNDED:  [],
}

Now transition(current, target) is a lookup, illegal moves fail with a message naming both ends, "terminal" is derived (no outgoing edges), and the whole lifecycle fits on one screen. When each status needs real behaviour (different validation, side effects, timers), the State pattern from its own lesson gives each status a class; for a lifecycle that is mostly "which moves are legal", an enum and a table are clearer and cheaper.

Exhaustiveness

Adding ON_HOLD must force every piece of code that reasons about statuses to decide what to do with it. Languages give that guarantee in different ways:

The common trap in every language is default:. A default branch tells the compiler "I have handled everything else", so a new member falls into it silently. Prefer listing every member and keeping the default only for genuinely open sets.

Parsing and serialisation

The wire form is a contract with other services and with your own past. Use stable lowercase names, not ordinals, and not the language's default representation if that might change. Parse strictly, in one place, and decide deliberately what an unknown value means. For a command ("set status to X"), reject it. For data from a newer producer (a status added by a service that deployed first), a tolerant reader may map it to an explicit UNKNOWN member and skip the record, so an old consumer does not crash on a new value. Protocol Buffers bakes this in: proto3 enums must start with a zero value conventionally named *_UNSPECIFIED, and unrecognised numbers are preserved rather than rejected.

Reviewing agent-written code

Asked to "add an on-hold status", an agent will often grep for "shipped", add "on_hold" beside it in a few places, miss the others, and add a default branch where the compiler would have complained. Point it at the single enum, the parse function and the transition table, and ask for a test that iterates over every member. In review, check for raw string comparisons outside the parse function, default branches over closed enums, ordinals written to storage, and whether consumers deploy before producers start emitting the new value.

The same shape elsewhere

An enum with a parse function is the trust but verify shape: outside data is untrusted until one gatekeeper validates it into a closed set. Kernels validate syscall numbers against a table and SQL CHECK (status IN (...)) constraints do it at the storage layer, each deciding spellings, aliases and the unknown-value policy.

Down the stack

Layer 1The contract

An enum promises a closed set: every value of the type is one of the declared members, members are distinct, and comparison is by identity or by a small integer. The parse function promises that every accepted input maps to exactly one member, that known aliases map to their canonical member, and that every other input fails with an error naming it. Serialisation promises that parse(to_wire(m)) == m for every member and that the wire name of a member never changes once published. The transition table promises an entry for every member.

Layer 2The mechanism

Python's Enum is built by the EnumType metaclass when the class body runs. Each member is a singleton instance created once, stored in _member_map_ (by name) and _value2member_map_ (by value), so OrderStatus("paid") is a dictionary lookup and is comparison is safe. Members pickle by name. StrEnum (Python 3.11) makes members real strings, which compares equal to "paid" and also lets raw strings leak back in.

TypeScript numeric enums compile to an object with a reverse mapping (Status[Status["Paid"] = 1] = "Paid"), so Status[1] is "Paid". String enums have no reverse mapping, const enum is inlined at use sites and breaks under single-file compilers, and the --erasableSyntaxOnly flag added in TypeScript 5.8 rejects enums entirely so code can run under type-stripping. A union of string literals erases to nothing, which is why many codebases prefer it.

Go's iota numbers constants 0, 1, 2 within a const block, and the stringer tool generates a String() method that slices one concatenated string using an offsets array (_Status_index), so there is no map and no allocation. Implementing encoding.TextMarshaler and TextUnmarshaler makes encoding/json read and write the names. Java's EnumSet is a bit vector in a single long for up to 64 constants, and Rust stores a discriminant plus the largest payload, using spare bit patterns so Option<&T> is the size of a pointer.

Layer 3The machine

A plain enum is a small integer: one byte in Go with uint8, four bytes in C by default, and a pointer to a shared singleton in Python and Java. Comparing two members is one instruction, against a memcmp for strings (a few nanoseconds for short strings, more for long ones). A switch over dense values compiles to a jump table: a bounds check and an indirect jump, about 1 to 3 ns, or under 1 ns when predicted. A transition table over six statuses fits in six bytes as bitmasks, allowed[from] & (1 << to), one load and one AND.

Storage is where size adds up. A Postgres enum column is 4 bytes per row and sorts in declaration order; a text column holding "delivered" is 10 bytes (1-byte header plus 9 characters) and sorts alphabetically. Across 500 million order rows that is roughly 3 GB of heap and index space. The catch is change: Postgres can add enum values with ALTER TYPE ... ADD VALUE but cannot drop one without rebuilding the type, so fast-changing sets often use a smallint or text column with a CHECK constraint or a reference table instead. On the wire, a protobuf enum is a varint (1 byte for values under 128), while JSON carries the name, a few bytes more and far easier to debug.

Why it's built this way

  1. Free-form strings drift in spelling across writers, so the set of legal values is declared once as a type.
  2. Programs change and sets grow, so the compiler or a test must find every place that does not handle a new member, which only works if the set is closed and switches avoid defaults.
  3. Memory representation is an implementation detail but storage and wire formats outlive the code, so serialise stable names, not positions.
  4. Other systems send values you do not recognise, so parsing happens at one edge with an explicit policy for unknowns.
  5. Lifecycle rules are data about the set, so they live in a table keyed by member rather than in conditionals spread across services.

Diagram

stateDiagram-v2
  [*] --> pending
  pending --> paid
  pending --> cancelled
  paid --> shipped
  paid --> cancelled
  paid --> refunded
  shipped --> delivered
  delivered --> refunded
  cancelled --> [*]
  refunded --> [*]

Worked example

A retailer's Java order service mapped OrderStatus with JPA, whose default @Enumerated mode is ORDINAL, so the status column held 0 for PENDING, 1 for PAID, and so on. A developer added AUTHORIZED between PENDING and PAID, because that is where it belongs in the lifecycle. The deploy shifted every later member up by one: 1.4 million paid orders now read as authorised, shipped orders read as paid, and the fulfilment job started re-shipping about 2,000 orders before an alert on duplicate tracking numbers fired 40 minutes later.

Recovery took two days of reconciling writes from the audit log. The lasting fix: columns became text with a CHECK constraint listing the wire names; the enum gained an explicit wire field and one parse method with aliases for legacy spellings; a test asserted every member's wire name against a checked-in list, so reordering fails CI; and adding a status became a two-step rollout, consumers first. Storage grew by about 1.2 GB across 150 million rows, which the team judged cheap insurance.

Trade-offs

OptionStrengthsWeaknessesChoose when
Raw strings or intsNo ceremony; any value acceptedTypos and drift; no exhaustivenessPrototypes, truly open sets
Language enum, serialised by nameClosed set; readable wire format; safe reorderingA few bytes more than ordinalsMost business enums
Enum serialised by ordinalSmallest storage and wire sizeReordering or inserting corrupts dataNever for persisted data, except with explicit fixed numbers (protobuf)
String literal union (TypeScript)Zero runtime cost; exhaustive checks; erasableNo runtime list unless declared separatelyTypeScript code, especially with type-stripping
Sum types with payloads (Rust, Swift)Each variant carries exactly its dataHarder to store in a flat columnRich domain states, parser results, errors
Reference table in the databaseValues changeable without a deploy; labels editableNo compile-time checks; joinsSets owned by operations, not by code

Pitfalls in production

Leadership lens

Check yourself

  1. Why is serialising an enum by ordinal dangerous even if nobody ever deletes a member?

    Show answer

    Inserting a member anywhere except the end shifts the ordinals of every later member, so stored numbers now decode to different members without any error.

  2. Why does a default: branch weaken an exhaustive switch over an enum?

    Show answer

    It tells the compiler every remaining case is handled, so a newly added member falls into the default silently instead of producing a compile error at each switch that must decide what to do with it.

  3. A consumer receives a status value it does not recognise. When should it reject the message and when should it map it to UNKNOWN?

    Show answer

    Reject when the value is a command or input you must act on correctly; map to UNKNOWN and skip when the data comes from a newer producer and failing would block unrelated processing.

  4. Why might you derive "terminal" from the transition table instead of storing it as a separate flag?

    Show answer

    A terminal status is one with no outgoing transitions; deriving it keeps one source of truth, so adding a transition cannot leave a stale flag behind.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →