Deploying and releasing are two different events, and conflating them is what makes deployment frightening. Once code can ship dark, a deploy becomes a routine, reversible operation, and turning a feature on becomes a decision someone can make — and unmake — in seconds without a pipeline run.
That is the entire value proposition, and it is large. The cost is a category of complexity that accumulates quietly and is genuinely expensive to clean up later.
Four kinds of flag, four lifecycles
Release flags hide unfinished work and should live for days or weeks, then be deleted. Experiment flags split traffic for measurement and expire when the experiment concludes. Operational flags — kill switches, load-shedding toggles, degradation modes — are permanent by design and belong to the on-call rotation. Permission flags gate features by plan or customer and are really part of your entitlements model, not your deployment tooling.
Mixing them is what produces the mess. A permission check implemented as a release flag ends up in the same list as a kill switch, nobody knows which are safe to remove, and the list grows until it is unusable. Tag them by type with an owner and an expected removal date at creation time.
@dataclass(frozen=True)
class Flag:
key: str
kind: Literal['release', 'experiment', 'operational', 'permission']
owner: str
expires: date | None # None only for operational and permission flags
NEW_PRICING = Flag('new_pricing_engine', 'release', 'billing-team', date(2026, 3, 1))
# Evaluation must be deterministic per subject, or a user flips between
# implementations on every request and you get support tickets you cannot explain.
def enabled(flag: Flag, subject_id: str, percentage: int) -> bool:
digest = hashlib.blake2b(f'{flag.key}:{subject_id}'.encode(), digest_size=8)
return int.from_bytes(digest.digest()) % 100 < percentage
# CI fails when a release flag is past its date. Flag debt is only removed
# if something insists.
def test_no_expired_flags():
stale = [f for f in ALL_FLAGS if f.expires and f.expires < date.today()]
assert not stale, f'expired flags still in code: {[f.key for f in stale]}'Rolling out without an audience of guinea pigs
- Ramp on a schedule with defined checkpoints — internal users, then one percent, then ten, then half — and specify in advance which metrics justify continuing.
- Automate the rollback trigger. A flag that requires a human to notice a spike at 2 a.m. is not a safety mechanism.
- Keep evaluation local with a cached ruleset; a network call per flag check on the hot path will eventually be the outage.
- Default to off when the flag service is unavailable, and make sure that default is the safe state rather than the new behaviour.
- Log which variant served each request. Without it, debugging a report means guessing which code path the user was on.
Every flag is a branch in production that your tests may never take together. Two flags are four paths; ten are more than you will ever verify.
That combinatorial point is the real argument for aggressive deletion. It is not tidiness — it is that flags interact, and a system with forty live flags has a state space nobody has tested and nobody can reason about. Delete release flags the week the feature is fully on, treat the expiry test as a build failure rather than a warning, and keep the permanent flags few enough that the on-call engineer knows all of them by name.