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.

From Stale Flag to Contracted Schema Five steps. Inventory flags linked to schema migrations; prove each is fully rolled out and has not been evaluated false recently; hard-code the winning branch and delete the losing branch; remove the flag from the flag service; run the contract migration that drops the old schema. From Stale Flag to Contracted Schema STEP 1 Inventory flags ↔ migrations STEP 2 Prove rollout 100% + no false evals STEP 3 Delete dead branch hard-code the winner STEP 4 Remove flag flag service STEP 5 Contract schema drop old structure
Removing the flag and its dead branch is what unblocks the schema contract — until then the old structure must stay.

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.

Schema Flag Inventory (Example) Matrix listing example schema-related flags with their rollout state, age at 100 percent, the schema they guard, and the cleanup action. Schema Flag Inventory (Example) Flag State Schema guarded Action read_region_from_new_column 100% for 140 days orders.region_old delete branch, drop column dual_write_customer_addresses 100% for 95 days trigger + old columns remove dual-write, contract use_orders_v2_table 100% for 30 days orders_legacy wait one more release new_invoice_numbering 0% for 200 days invoices.number_v2 abandoned: drop new column
A small inventory like this, reviewed monthly, is usually enough to keep schema flags from outliving their migrations.

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:

  1. 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).
  2. Deploy and let the rollout complete everywhere — web, workers, scheduled jobs.
  3. Delete the flag from the flag service.
  4. 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.

Schema Flags Over Time Stacked bars of schema-related flags per quarter, split into active rollouts and stale flags at 100 percent. Before a cleanup process, stale flags grow from 12 to 61 over four quarters while active rollouts stay near 8. After introducing expiry dates and a monthly review, stale flags fall to 5. Schema Flags Over Time Q1 8 12 Q2 9 27 Q3 7 44 Q4 8 61 after process 8 5 active rollouts stale at 100%
Expiry dates and a monthly review keep stale flags near zero; without them the stale count only grows.

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.