Deploying code and exposing it are different acts. A feature flag can select a runtime path after the code is deployed. This supports gradual exposure and fast disablement, provided the old path remains compatible and the configuration reaches the processes that evaluate it. The flag controls a decision; it does not reverse everything the deployment or the new behavior has already done.
Ship behind a defined gate
A release flag commonly defaults to the established path while a new implementation is tested. Deploy-time behavior still changes: imports, startup hooks, schema migrations, dependencies, and resource use may run outside the guarded branch. An off flag therefore cannot promise zero user-visible effects. Test the deployed artifact with the flag off as well as on, including startup and compatibility with existing data.
Turning off the flag can stop later requests from entering the new path. It does not undo completed writes, messages sent, migrations, or work already in flight. Keep data formats compatible with both branches during the rollback window and define any repair required after partial execution. A kill switch is useful only when its propagation delay and actual stopping boundary match the incident response plan.
Choose defaults by meaning
For an unfinished release feature, an unknown configuration may safely select the old path. That is a decision about this flag’s semantics, not a universal rule that false is always safe. Disabling a protective check or reversing a flag named disable-validation can have the opposite effect. Specify fallback behavior, configuration freshness, and what happens when the service cannot evaluate the flag.
Evaluate a release decision once at the appropriate operation boundary and reuse it within that operation. Multiple services or separate requests need an explicit way to carry compatible decisions; local evaluation alone does not create global consistency. Keep authorization enforced independently. A client-side display flag must not become permission to perform a protected server-side action.
Ramp exposure with stable identities
Hashing a stable user identifier with a stable flag key gives repeatable cohorts. Changing the identifier, salt, or bucketing algorithm can reshuffle users even when the percentage stays unchanged. A percentage of users is also not necessarily the same percentage of requests: a small cohort can generate disproportionate traffic. Record the unit of assignment before interpreting rollout metrics.
A kill switch should override the cohort selection and have a tested delivery path. SDK caches and configuration outages can leave some instances on older values. Observe errors and latency by actual evaluated variant, rather than treating a control-panel change as proof that every request has switched. Choose a rollout pace from the service’s risk and observation delay, not an arbitrary universal schedule.
When it breaks
Temporary release flags can become permanent branches when nobody owns their removal. Give each one an owner and a retirement condition, and remove obsolete paths after the rollback window closes. Operational switches and entitlement controls may deliberately remain long-lived; they need ongoing tests and ownership rather than an unconditional short expiry.
Contradictory flags can create combinations nobody tested. Centralize the decision rule where practical, define precedence, and exercise both the fallback and enabled paths. Logs should record enough nonsecret evaluation context to diagnose disagreement, while avoiding unbounded retention of real user identifiers. The example below uses synthetic names only.
Check it quickly
Save the block as flag_check.py and run it with Python 3.10 or newer. The original fixture reports Python 3.14.4; the retained 2026-10-15 review reran its decision logic with Python 3.12.14. It models one percentage release flag, a kill switch, stable user bucketing, and an in-memory audit list. The percentage is assumed to be a validated value from zero through one hundred.
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 two flag keys 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; different flag keys 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, missing users stay off, the kill switch overrides every named user, and repeated evaluation is stable. The fixed two-hundred-user cohort produces zero, thirty-five, and two hundred enabled users at zero, twenty, and one hundred percent. Thirty-five is a deterministic fixture result, not a guarantee of exactly twenty percent in every finite population.
The second evaluator changes the flag key while retaining the same user IDs. Its different decisions demonstrate salt/key drift, not an actual session-ID experiment. The audit counts verify one entry per evaluation. No distributed propagation, request-wide reuse, database rollback, or authorization is tested by this local model; those remain separate integration responsibilities.
Further reading
Sources
- Feature Toggles (Pete Hodgson), the toggle taxonomy, checked 2026-10-15.
- LaunchDarkly: what are feature flags, progressive exposure, checked 2026-10-15.
- 12 Factor: build/release/run separation, where config and flags sit, checked 2026-10-15.