Deploying code and exposing it are different acts. A feature flag is a runtime switch that decides whether shipped code is active, letting you release continuously and switch behaviour without redeploying.
Ship hollow, then turn on
A feature flag splits the moment code ships from the moment users see it. The default value is off, the new code path is hidden behind the flag, and shipping changes nothing visible until the operator flips it. That decoupling is what lets a risky feature ride the normal release train: the deploy is reversible by turning a flag off, which is a faster rollback than replacing a build. Release and exposure become two separate verbs, so you can ship on a Tuesday and light the feature up when the room is actually watching. The empty shell also lets you validate deploy-time behavior — pod count, startup errors, metrics — with zero user-facing change.
Evaluate the flag once per request and treat an unknown flag as off, so a configuration outage never becomes a surprise launch.
Ramping and killing are the superpowers
The value arrives once the flag supports more than on/off. A percentage dial lets you expose the feature to ten percent of traffic, watch the error budget, and raise the dial instead of risking a full cutover at once. The killer power is the off switch: when a flagged feature misbehaves in production, turning its flag off removes the behavior without an emergency deploy. Resist the urge to treat a flag as permanent; flags that are never cleaned up accumulate dead configuration, and a flag stuck at always-on starts claiming limits and ports. Set the cadence to review and delete a flag shortly after it is fully rolled.
Stickiness matters more than the exact percentage. Bucket by a stable key such as the user ID; bucketing by timestamp or a random flip shows both halves to the same person and poisons the comparison.
Keep flags honest
A corollary of flag volume: a haphazard pile of them becomes a jigsaw of hidden runtime branches. Name flags to be self-describing, document their owner and default, and keep the branches in the code honest so there is exactly one place a behavior is defined. Use the system as intended, including pushing the fallback path when the flag is off, because bugs that only appear with the flag on do less damage when a shoulder-to-shoulder promotion is their only elevator. Simplest guidance: flags should be short-lived gates, not a permanent palette of halves.
Give every flag an owner and an expiry date, and file the removal task before the rollout starts.
When it breaks
Flag drift is the most common failure: a flag reaches one hundred percent, removal is never scheduled, and months later both branches survive. The temporary gate has become a permanent fork.
The mirror failure is a default that opens instead of closes. A service reads an unset flag as on, or the client fails open when unreachable, so a hollow deploy exposes unfinished work to everyone at once. Prefer clients that fail closed.
Evaluation can also disagree with itself inside one request: the page buckets by user ID while the write path buckets by session ID, or a mid-request change flips the answer halfway through. The user sees the new layout but submits through the old handler. Evaluate once per request, reuse the decision, and log the reason.
Finally, orphaned flags accumulate until a renamed flag silently falls back to its default and two overlapping flags contradict each other with no defined winner. Audit which flags were evaluated and delete the flag in the release that finishes the rollout.
Check it quickly
Save the block as flag_check.py and run python3 flag_check.py with Python 3.10 or newer. It ran with Python 3.14.4 using only the standard library. The script models one percentage flag with a kill switch, per-user bucketing, and an audit log.
import hashlib
class Flag:
"""One percentage flag with kill switch, default-closed, and audit log."""
def __init__(self, key, percent):
self.key = key
self.percent = percent
self.killed = False
self.audit = []
def _bucket(self, user):
digest = hashlib.sha256(f"{self.key}:{user}".encode()).hexdigest()
return int(digest, 16) % 100
def evaluate(self, user):
if self.killed:
decision, reason = False, "killed"
elif not user:
decision, reason = False, "default-closed"
else:
hit = self._bucket(user) < self.percent
decision, reason = hit, ("in-bucket" if hit else "out-of-bucket")
self.audit.append({"user": user, "decision": decision, "reason": reason})
return decision
flag = Flag("new-checkout", 20)
users = [f"user-{i}" for i in range(200)]
# 1. Default closed: a missing user never gets the new path.
assert flag.evaluate("") is False
assert flag.evaluate(None) is False
# 2. Stickiness: the same user gets the same answer twice.
first = flag.evaluate("user-1024")
second = flag.evaluate("user-1024")
assert first == second
# 3. Kill switch overrides every bucket.
flag.killed = True
assert flag.evaluate("user-1024") is False
assert flag.evaluate("user-7") is False
flag.killed = False
# 4. The dial moves exposure for a fixed fixture set.
open_20 = sum(flag.evaluate(u) for u in users)
flag.percent = 100
open_100 = sum(flag.evaluate(u) for u in users)
flag.percent = 0
open_0 = sum(flag.evaluate(u) for u in users)
flag.percent = 20
assert open_0 == 0
assert 0 < open_20 < len(users)
assert open_100 == len(users)
# 5. A different bucketing key disagrees somewhere: the inconsistency to avoid.
session_flag = Flag("new-checkout-session", 20)
mismatch = [u for u in users if flag.evaluate(u) != session_flag.evaluate(u)]
assert mismatch, "expected at least one user where user-key and session-key disagree"
# 6. Audit log holds one entry per evaluation with its reason.
assert len(flag.audit) == 2 + 2 + 2 + 200 * 3 + 200
assert len(session_flag.audit) == 200
assert all(set(e) == {"user", "decision", "reason"} for e in flag.audit)
print(f"default-closed empty user: off; sticky user-1024: {'on' if first else 'off'} twice")
print(f"dial 0/20/100 over 200 users: {open_0}/{open_20}/{open_100}")
print(f"killed flag forces off; user-key vs session-key disagree on {len(mismatch)} users, e.g. {mismatch[0]}")
print(f"audit entries: flag={len(flag.audit)}, session_flag={len(session_flag.audit)}")
print("PASS: default-closed, sticky bucketing, kill switch, dial, and audit log behave as asserted")
At twenty percent, an empty user stays off, a killed flag forces every named user off, and the same user receives the same decision twice. Over two hundred fixture users the dial reads zero, thirty-five, and two hundred. A second evaluator keyed by session disagrees on dozens of accounts, which is the inconsistency the single-evaluation rule prevents. The audit log holds one entry per evaluation with its reason.
Further reading
Sources
- Feature Flags: pattern (Martin Fowler), the toggle taxonomy, checked 2027-02-07.
- LaunchDarkly: what are feature flags, progressive exposure, checked 2027-02-07.
- 12 Factor: build/release/run separation, where config and flags sit, checked 2027-02-07.