Cleaning Up Stale Schema Feature Flags
The flag service lists 214 flags, and 61 of them have names like use_orders_v2_table, read_region_from_new_column or dual_write_customer_addresses. Each was created to roll out a schema change safely. Each was flipped to 100% months ago. None was removed, so the codebase still contains both branches of every one — the old read path, the dual-write, the fallback to a column that may no longer exist. Worse, the schema cannot be contracted: orders_legacy still exists because a flag-guarded code path might read it, and nobody is sure whether anything evaluates that flag to false anywhere. Flags are an excellent tool for rolling schema changes out; left in place, they become the reason the old schema can never be removed. This guide sets up a process to find stale schema flags, prove they are safe to remove, delete them together with their dead code, and then finish the schema contract. It belongs to Feature Flag Rollouts.
Symptom / Error Signatures
Stale schema flags are visible in code, in the flag service and in the database:
- Flags at 100% (or 0%) for longer than the rollout period, with no targeting rules, whose names reference tables or columns.
- Code with
if flags.enabled("read_region_from_new_column")branches that tests exercise only on one side. - Old tables and columns that cannot be dropped because “a flag might still use them”, and dual-write triggers still running long after the migration finished.
- Incidents in which someone toggled an old flag during unrelated debugging and the application started reading a legacy table that had stopped being maintained.
Root Cause Analysis
A schema rollout flag couples three things: a code path, a piece of schema that path depends on, and a runtime switch. The rollout procedure in coupling schema changes to feature flags and removing both ends with removing all three, but the last steps have no deadline and no owner, so they are skipped once the feature works. The result is a flag that can still route traffic to schema that is no longer maintained — the most dangerous kind of stale flag, because flipping it silently reads wrong data rather than failing.
| Flag state | Risk if flipped | Blocks schema contract? |
|---|---|---|
| 100% on, no rules, code has both branches | reads old schema that may be stale | yes |
| 100% on, dead branch deleted, flag still defined | none in code; clutter in flag service | no |
| partially rolled out | real rollout in progress | yes (correctly) |
| 0% (abandoned) | new schema unused | new structure should be removed instead |
The removal order matters: the losing code branch must be deleted and deployed everywhere before the flag disappears from the flag service (so no code evaluates a missing flag with a surprising default), and the schema contract must wait until that deploy has fully rolled out.
Immediate Mitigation
1. Build the inventory. Export flags from the flag service with their last change date and rollout percentage, and search the codebase for each flag key.
# Shell · repository root · finds code references for each flag key listed in flags.txt
while read -r flag; do
refs=$(git grep -l -- "\"$flag\"" | wc -l)
printf '%s\t%s\n' "$flag" "$refs"
done < flags.txt | sort -k2 -n
2. Prove each candidate is effectively constant. Most flag services report evaluation counts per variation; the requirement is zero evaluations of the losing variation over a period covering weekly and monthly jobs. Where evaluations are not recorded, add a log line or metric on the losing branch for one release and confirm it never fires.
3. Freeze the flag. Remove targeting rules and lock the flag at its winning value so nobody can flip it during cleanup.
Permanent Fix / Long-Term Pattern
Remove flags in a fixed order, one flag per pull request:
- Replace every evaluation with the winning branch; delete the losing branch and any code that only it used (old repository methods, ORM mappings for the old column, fallback reads).
- Deploy and let the rollout complete everywhere — web, workers, scheduled jobs.
- Delete the flag from the flag service.
- Run the schema contract: drop the old column, table or dual-write trigger, following Expand and Contract Methodology and the dependency checks in dropping constraints safely during the contract phase.
-- PostgreSQL · contract migration after the flag and its dead branch are gone
-- WARNING: run only after query statistics confirm no reads of region_old.
SET lock_timeout = '3s';
DROP TRIGGER IF EXISTS orders_region_sync ON orders;
ALTER TABLE orders DROP COLUMN IF EXISTS region_old;
-- ROLLBACK PATH: none for data; restore from backup if the column is unexpectedly needed.
Prevent the backlog from forming again. Give every schema flag an expiry date and an owner when it is created; record, in the migration’s pull request, which flag guards it and which release will remove both; and add a CI check that fails when a flag past its expiry is still referenced in code. Treat the cleanup as part of the migration’s definition of done, alongside the checks in Migration Pipeline Gating.
Tests need attention during cleanup too. Test suites often parametrise over both flag values, so deleting a branch leaves tests that set a flag that no longer exists; remove them with the branch, and keep only tests for the winning behaviour. Integration tests that seed the old schema (for example, fixtures that insert into orders_legacy) must be updated before the contract migration, or CI will fail on the drop for reasons unrelated to production.
Verification Checklist
Frequently Asked Questions
Why not just leave a 100% flag in place? Because it keeps both code paths alive and blocks removal of the old schema. It also leaves a switch that can silently route traffic to structures nobody maintains any more.
What should the order of removal be? Delete the losing code branch and deploy it everywhere first, then remove the flag from the flag service, then contract the schema. Reversing the first two can make code evaluate a missing flag with an unexpected default.
How do I know no job still evaluates the flag to false? Use your flag service’s evaluation metrics, or add a metric on the losing branch for a release, and wait through weekly and monthly job schedules before concluding it is unused.
What about flags that were never rolled out? An abandoned flag at 0% means the new schema is unused. Remove the new code path and the new schema — the contract step happens on the new structure instead of the old.