The flag dashboard for our checkout service listed more flags than the service had routes. Some had names like checkout_v2_final. Some had never been turned off since the day they shipped. None of them had an owner I could name.
A flag is a branch that never gets deleted
if (flags.isEnabled("new_pricing_engine")) looks harmless. It is a conditional, and conditionals are normal. The difference is who owns removing it. A business rule gets deleted when the business rule changes. A flag gets deleted when someone remembers it exists.
Flag debt is quiet. It does not break a build, does not show up in a profiler, and does not page anyone. It shows up in onboarding, when a new engineer asks which combination of flags is live in production and the honest answer is that nobody has enumerated them since the last time someone was brave enough to try.
I have read a function with several nested flag checks and could not tell you which paths execute in production. Neither could the person who wrote it.
Release flags, kill switches and experiments age differently
Treating every flag as one category is where the mess starts. They have different lifetimes.
A release flag guards a deploy. It exists so a rollout can be paused or widened. Its natural end is the next release, or the one after that. Days to weeks.
An ops flag, or kill switch, guards a dependency or a risky path. It lives as long as the risk does, which can be months. Kill switches are the only flags I would argue should be permanent, and even they need a named owner and a written trigger for flipping them.
An experiment flag answers a product question. It ends when the experiment ends, when someone reads the data and writes down the decision. If the decision never gets written down, the flag quietly becomes configuration.
So I put the intended lifetime in the flag metadata, not in a wiki page nobody opens. A release flag with no expiry date is a release flag that already failed.
"We will clean it up later" is not a plan
Later is a sprint with a different priority. The flag is invisible in the backlog because nobody files a ticket for code that works. Meanwhile the flag does real damage.
Combinatorics are the part people underestimate. Every boolean flag doubles the number of reachable configurations. Twenty flags is over a million combinations. CI exercises one or two of them. Your test suite is not verifying the product; it is verifying the default path and hoping the rest holds. When a bug shows up in production, the first debugging step becomes working out which flags that account had enabled, which is a query against a system that may not have kept the data.
Every flag also has to survive refactors. Rename a module, split a service, move a queue, and the flag check follows. Each move is a chance to silently change its default.
Make expiry a failure, not a reminder
The fix is not discipline. Discipline is what we already tried. The fix is a registry where expiry date and owner are required fields, checked in CI.
from dataclasses import dataclass
from datetime import date
from typing import Literal
@dataclass(frozen=True)
class Flag:
name: str
owner: str # a team, not one person who might transfer
kind: Literal["release", "ops", "experiment"]
expires_at: date
ticket: str
FLAGS = {
"new_pricing_engine": Flag(
name="new_pricing_engine",
owner="payments",
kind="release",
expires_at=date(2026, 10, 14),
ticket="PAY-4471",
),
}
def test_flags_have_not_expired() -> None:
today = date.today()
stale = [
flag
for flag in FLAGS.values()
if flag.expires_at < today and flag.kind != "ops"
]
assert not stale, f"expired flags must be removed or renewed: {stale}"
That test runs in the same job as the unit tests. An expired flag turns the pipeline red, and the branch either comes out of the code or the expiry gets extended on purpose with the ticket updated. Both outcomes are fine. Silence is not. The pull request that extends an expiry is a reviewable decision; the flag that drifts for a year is not.
I also log every flag evaluation with the flag name and the variant served. That answers the question the dashboard cannot: which flags are actually being evaluated, and how often. A flag with zero evaluations across a measurement window is a deletion candidate today. Do not guess at that from the admin UI. Instrument it and count the evaluations yourself.
Removal has to be the default path, not a cleanup project. When a release flag expires, the branch goes away with it. When a kill switch stops protecting anything, someone deletes it and closes the ticket. That is the whole system: an owner, an expiry date, and a CI check that refuses to let either one slide.
I write about production failures in Postgres, queues, and distributed systems.













