A Go service numbers ticket statuses with iota and stores them as ints. What breaks when someone inserts a new status in the middle of the const block?
answer
- the numbers are positional, not declared
- one-word diff, wide blast radius
- the compiler has nothing to check
- a stored value is a contract
- pin values, or persist the name
basics
~20 sEvery constant below the insertion point shifts up by one, so rows written under the old numbering now read back as a different status, and nothing fails to compile. Pin persisted values explicitly and only append.
solid answer
~50 s`iota` numbers by line position, so inserting a constant renumbers everything below it. The code still compiles - these are ordinary ints and Go has no enum the compiler can police - but every ticket stored as 2 now reads back as the status that used to be 3. At 3am that looks like tickets spontaneously changing state, and the diff is one word. The fix is to stop letting line position define an external contract: give persisted constants explicit values (`StatusOpen Status = 1`), treat the numbers as append-only, or persist the name and keep `iota` for in-memory values. Guard it mechanically: `//go:generate stringer -type=Status` emits a compile-time index assertion that breaks the build when a value moves, and a golden test over each constant's number and `String()` output turns a silent renumbering into a red build.
code
go · 9 linestype Status int
// Wire and database values. Append only; never renumber.
const (
StatusOpen Status = 1
StatusPending Status = 2
StatusClosed Status = 3
StatusTriage Status = 4 // added later, out of workflow order
)go deeper
Understand that these constants are just integers whose values come from their position in the const block, so moving a line changes what the numbers mean.
Trace the failure end to end: insertion shifts every value below, stored rows keep the old number, and the mismatch appears only when old data meets new code.
Show the production judgment: pin persisted values or store names, keep numbering append-only, and add a generated compile-time check plus a golden test so the renumbering breaks the build instead of the data.
Own the rule that a persisted or published enum is an interface with a compatibility policy, decide who may change it and how a rollout with two numberings live is prevented.
### The scenario A support-ticket workflow service declares its statuses the idiomatic way: ```go type Status int const ( StatusOpen Status = iota StatusPending StatusClosed ) ``` The numbers are written to a `status` integer column, published on an internal queue and counted in a dashboard. Someone adds a triage step and, reasonably enough, puts it where it belongs in the workflow: ```go const ( StatusOpen Status = iota StatusTriage // inserted here StatusPending StatusClosed ) ``` ### What actually happens `iota` is the index of the line, so `StatusPending` moved from 1 to 2 and `StatusClosed` from 2 to 3. Nothing in the language notices. The constants have a defined type, but the *values* are ordinary integers and the numbering is an emergent property of line order, not a declaration of intent. Every existing row storing 1 now means `StatusTriage` to the new binary; every row storing 2 means `StatusPending`. Two versions of the service running side by side during a rollout disagree about what a queue message means. The on-call symptom is not an error. It is closed tickets reappearing as pending, a dashboard whose status histogram shifts by one bucket at deploy time, and alerting rules that stop matching. Nothing panics, nothing logs, and the offending diff line looks like a one-word addition. ### Why the compiler cannot help Go has no enum type. `Status(2)` is a legal conversion from any integer, a value decoded from JSON or scanned from a database column is just a number, and a `switch` over `Status` that has no case for a value simply falls through. There is no ordinal keyword to be stable, no exhaustiveness check, and no persistence metadata for the compiler to compare against. The safety you feel from `type Status int` is real but shallow: it stops the wrong *type* being passed, never the wrong *number*. ### The design fix: stop letting position define the contract Rank these by how much you can change: 1. **Persist the name, not the number.** A `status` column holding `"pending"` cannot be renumbered by an edit to a const block, is readable in a query, and makes an unknown value obvious. It costs a few bytes and a mapping function at the boundary. For anything crossing a process, this is usually the right call. 2. **Pin the values explicitly and treat them as append-only.** Write `StatusOpen Status = 1` and so on, with a comment saying the numbers are a stored contract. New statuses get the next free number regardless of where they belong in the workflow; retired ones leave their number behind, marked `_` or a comment, never reused. 3. **Keep `iota` for in-memory values only** — flags, priorities, state that never leaves the process — and convert explicitly at every boundary. The common mistake is to "fix" the ordering problem by sorting the const block or by moving a constant to keep the workflow readable. Readability of the block is worth nothing next to the stability of a stored number. ### The mechanical guards Generating the `String()` method with `//go:generate stringer -type=Status` does more than pretty-print. The generated file contains a function with an array-index expression per constant, so if a value moves, the generated index no longer matches and the package fails to compile with an invalid-array-index error. That converts a silent renumbering into a build failure — provided regeneration is a deliberate step and not something a pre-commit hook does silently. On top of that, a golden test is cheap and catches everything the generator does not: assert both the number and the string of every constant. ```go func TestStatusWireValues(t *testing.T) { want := map[Status]string{ 1: "StatusOpen", 2: "StatusPending", 3: "StatusClosed", } for v, name := range want { if got := v.String(); got != name { t.Errorf("Status(%d) = %q, want %q", int(v), got, name) } } } ``` A reviewer who sees that test fail knows immediately that a stored contract changed, which is exactly the signal the const block itself cannot give. ### If it has already shipped Stop the rollout before both numberings are live at once. Decide which numbering is authoritative — almost always the old one, because the data is already written — restore it by pinning explicit values, and give the new constant a number at the end. If mixed data was written, you need a mapping from the affected time window, keyed by whatever else distinguishes the rows, and that is usually painful enough to be the argument for storing names next time. ### Boundary validation, either way Whichever representation you choose, parse rather than convert at the edge: a function that maps an incoming integer or string to a `Status` and returns an error for anything unrecognised. It costs one small function and it turns "tickets are in the wrong state" into a rejected message with a clear log line.
- How does a generated String method turn a renumbering into a build failure?The generator emits an unused function containing one array-index expression per constant, such as `_ = x[StatusOpen-1]`. Those indexes are valid only while the constants hold the values they had at generation time; move one and the expression indexes out of range, which is a compile-time error. Regenerating deliberately is what makes the change visible.
- Is storing the status name instead of the number always the better choice?Not always. Names cost space and a mapping function, and a rename becomes the dangerous edit instead of a reorder. But they are self-describing in queries, immune to reordering, and make an unknown value obvious in a log. For anything crossing a process boundary the readability usually wins; for a hot in-memory field, keep the integer.
- What do you do about a value that has been retired from the enum?Leave its number occupied — a line declaring `_`, or an explicitly numbered constant marked deprecated — so no new status inherits it. Old rows still hold that number, and reusing it would make historical data mean something it never meant.
- How would you catch this class of bug in review rather than at 3am?Treat any edit inside a const block whose values are persisted as a contract change: require the golden test to be updated in the same commit, and keep a comment at the top of the block saying the numbers are stored. A test that must be edited by hand is the signal reviewers actually notice.
saying these in an interview costs you the question
- Expects the compiler to catch renumbered constants
- Sorts or reorders the const block to keep it readable
- Reuses a retired constant's number for a new status
- Thinks the defined type prevents an out-of-range value
- Relies on a switch being exhaustive over the type