The identifiers a program uses to find its own records are written in the code and stored in the data. Renaming one is an edit to every record that already exists, whether or not you run a migration.
There is a rule in my operating notes that reads, in full: never rename a key, id or constant. Plan keys, price ids, collection names, field names and user ids are data. Renaming one silently breaks records that already exist.
It sounds like a style preference. It is not. It comes from noticing that the identifiers a program uses to find its own records sit in an awkward place: they are written in the code, so they feel like code, but they are stored in the data, so they behave like data. And the tools we use to change code safely know nothing about the data.
In a codebase, renaming is one of the safest things you can do. An editor finds every reference,
a compiler or a type checker complains about the ones it missed, the tests run, and the diff reads
as pure improvement. status becomes accountStatus, pro
becomes professional, and the code is clearer than it was.
That safety depends on one assumption: that every use of the name is somewhere the tooling can see. For a local variable it is true. For a field in a document database it is false. The field name is written into every document that already exists, and not one of those documents is in the repository. The type checker is satisfied, because the type checker has only ever seen the code.
A relational database at least forces the question. You cannot rename a column without a
migration, and the migration touches the rows. A schemaless store forces nothing. The new code writes
the new name, the old documents keep the old name, and the database is perfectly content to hold
both. Nothing errors. A query for accountStatus == "active" simply does not match the
documents that say status, and returns fewer results than it should — which, on a
quiet day, looks exactly like fewer results.
This is the failure I described in the scheduled job you cannot see fail as the silent zero: a query that used to match now matches nothing because a field was renamed. The code is correct. It is correct about a world that no longer exists.
The obvious ones are collection names and field names. The list is longer than that, and the longer items are the dangerous ones, because each has holders you cannot enumerate.
"active", "trial",
"cancelled". Change the spelling in code and every stored value becomes an unknown
state.localStorage and
sessionStorage on devices you will never have access to.For each of these, grep tells you where the name appears in your code. It tells you nothing about where the name appears in the world, which is the part that decides whether the rename breaks anything.
The worst version of a rename is not the one that throws. It is the one where an unknown key falls through to a default.
Consider a function that maps a plan key to what the customer is entitled to. If it is written as a
lookup with a fallback — find the plan, and if there is none, treat them as the free tier
— then renaming pro quietly downgrades every existing paying customer the moment
the new code ships. If the fallback runs the other way, and an unrecognised plan is treated as the
most capable one because that avoided a support ticket once, the same rename quietly upgrades every
record with a typo in it. Neither produces an error. Both are wrong in a direction that matters to
someone.
So the rule that pairs with never renaming is: an unknown key fails closed and loudly. A plan lookup that does not recognise the key raises, logs the key it did not recognise, and grants nothing extra. That turns a silent data problem into a visible one on the first request, which is the cheapest moment to find it.
The same class of bug turns up at boundaries between systems, not just between code and data. I
wrote about an endpoint that returned 200 and saved
successfully, then published a page reading undefined, because the sender used one
key and the renderer read another. The validator checked that the payload had the right shape and
never checked that the field the renderer depended on was present. A key is a contract between
whoever writes it and whoever reads it, and both sides have to agree on the exact bytes.
In a Firebase application the security rules are the authorisation layer, and they refer to fields by name. That creates a failure specific to renames.
Suppose a rule keeps a server-owned field immutable from the client: a user may update their own
profile, but not change their role. The rule compares the incoming value to the stored
one. Now the code moves to a new field name, and the rule is not updated. The guard is still there,
still passing, still protecting a field that no longer carries any meaning. The new field has no guard
at all.
The defence is to validate writes against an allowlist of permitted field names —
request.resource.data.keys().hasOnly([...]) — rather than checking only the
fields you thought of. With an allowlist, a write that introduces an unlisted name is rejected. A
rename that forgot the rules then fails on the first write, in development, which is where you want
it to fail. This is the same argument I make in Firestore security rules are
row-level security: reject what you did not anticipate, rather than permitting it.
Most renames are not really about the identifier. Someone wants the product to say “Professional” instead of “Pro”, or a column in an admin screen to read better. That is a change to a label, and a label is a different string from a key.
The fix is structural and it costs nothing if done at the start: every key has a display label stored or mapped separately. The key is short, lowercase, boring and permanent. The label is whatever marketing wants this quarter. Changing the label is a copy edit with no data consequences. Changing the key is a migration, and it should feel like one.
Boring matters. A key named after a feature's launch name, a partner, a price point or a
customer's company is a key that will one day be wrong, and the pressure to rename it will arrive
with it. tier_2 ages better than growth_49.
Sometimes a key is actually wrong: it misdescribes the data in a way that causes bugs, or two concepts were collapsed into one field and have to be separated. Then it is a migration, and it has the shape every schema migration has, expand, migrate, contract:
count() of documents still carrying the old field costs a fraction of
reading them, and the answer you want is zero.For the keys you do not hold, the contract step never fully happens. An old URL slug keeps a permanent redirect for as long as the site exists. An old browser storage key is read once, copied to the new one and deleted on the device, and the reading code stays until you are confident no device still has it, which is a long time. An old price identifier stays mapped until the last subscription on it has ended.
The useful way to think about a key is that its lifetime is not the lifetime of the code that uses it. It is the lifetime of the oldest record, token, link or device that holds it. For most of the keys in a production system that is “indefinitely”.
Once you see it that way, the rule stops sounding fussy. You choose a key once, you choose it dull, you put the pleasant words in a label beside it, and you treat any later change to it as what it actually is: an edit to every record you have ever written.
Written by Liana Grigory, Entrepreneur and Software Engineer, from work on The Care Royal, Tegula Stone and Unified Savers. Everything above describes decisions actually made on those systems, including the ones that turned out to be wrong.