A code change is often ready to deploy before it is ready to expose to every user. A team may want to test a new workflow with internal users, release it gradually, or keep unfinished behavior inactive while several changes reach production.
A feature flag provides that control. It is a runtime decision that selects between behaviors without requiring a new deployment for every change in exposure.
That sounds simple, but feature flags create their own engineering cost. Every active flag can introduce another path through the system. If flags accumulate, developers eventually have to reason about combinations of old and new behavior that nobody intended to keep forever.
The useful mental model is therefore not “a flag is a configuration option.” It is temporary control over a transition. A good flag has a purpose, an owner, expected behavior when its value cannot be read, and a plan for removal.
Separate deployment from release
Without a flag, deployment and release often happen together:
deploy new code -> every user gets new behaviorWith a flag, the new code can exist in production while the old behavior remains active:
deploy new code
|
v
is new_checkout enabled?
| yes -> new checkout
| no -> existing checkoutThis separates two decisions:
- Deployment: Is the code running in the production environment?
- Release: Which users or requests should receive the new behavior?
That separation is valuable when exposure needs to change independently of deployment. For example, a team can deploy during normal working hours, enable the feature for employees, observe the result, and expand access later.
A flag does not make a change safe by itself. It gives the team a control point. Safety still depends on whether both paths work, whether the flag can be changed reliably, and whether disabling it actually restores an acceptable state.
Keep the decision close to the behavior it controls
Consider a service choosing between an existing pricing calculation and a replacement:
function calculatePrice(order, flags):
if flags.enabled("new-pricing"):
return calculateNewPrice(order)
return calculateExistingPrice(order)The example is intentionally small. The flag controls one meaningful behavioral decision.
Problems begin when the same flag is checked throughout unrelated layers:
controller -> check flag
validator -> check flag
repository -> check flag
formatter -> check flagNow understanding the feature requires finding every check and reconstructing how they interact. Removing the flag also becomes harder because its lifecycle is spread across the codebase.
Prefer to evaluate a flag at a boundary that can select a coherent behavior. The code below the decision should usually behave like ordinary code rather than repeatedly asking whether the transition is active.
For a larger workflow, that might mean selecting one implementation:
if flags.enabled("new-checkout"):
checkout = NewCheckout(...)
else:
checkout = ExistingCheckout(...)
return checkout.placeOrder(request)This is not a universal rule. Some features genuinely affect several independent decisions. The important question is whether each flag check represents a real product or engineering decision, rather than being scattered because the flag is easy to access.
Decide what happens when flag evaluation fails
A runtime flag usually depends on some source of state: local configuration, a database, a configuration service, or a feature-management system. That dependency can fail or become temporarily unavailable.
Code therefore needs a defined fallback.
For a transition from an established implementation to a new one, a conservative default may be:
new-pricing unavailable or unknown -> use existing pricingFor another feature, falling back to the old path may be incorrect. Imagine a flag controlling whether a newly required validation rule is active. Silently skipping validation when the flag service fails could violate an important business requirement.
Choose fallback behavior from the semantics of the feature, not from a generic rule that flags should always default to false.
The same reasoning applies during application startup. If a particular flag is essential to correct operation, failing startup may be more appropriate than guessing a value. If the flag merely controls an optional experiment, a local default may be sufficient.
Write the expected fallback down where developers can discover it. Failure behavior is part of the design of the flag.
Test the behaviors, not every theoretical combination
Each independent Boolean flag can double the number of possible combinations. Five flags have 32 theoretical on/off combinations. Ten have 1,024.
That does not mean a team should write tests for every mathematical combination. Many flags never interact. Testing all combinations wastes effort while still failing to explain which interactions matter.
Start by testing each controlled behavior:
new-pricing = off -> existing calculation is used
new-pricing = on -> new calculation is usedThen add combination tests where flags actually influence the same workflow or state.
For example, if new-checkout and new-tax-calculation can both affect order totals, their interaction deserves explicit attention. A flag controlling an unrelated profile page probably does not need to appear in those tests.
This leads to a practical rule: test based on behavioral interaction, not flag count.
Also test the fallback path if flag evaluation can fail. A branch that exists specifically for operational recovery should not remain unverified.
Do not assume disabling a flag undoes side effects
A flag can change which code runs next, but it cannot automatically reverse changes that have already happened.
Suppose a new workflow writes data in a new format:
flag on -> write new representationAfter real traffic uses that path, switching the flag off does not make the new data disappear. The old implementation may even be unable to read it.
The same issue appears with messages, external API calls, emails, payments, and other side effects. Once an action has escaped the process, a Boolean switch cannot erase it.
Before treating a flag as a rollback mechanism, ask:
- Does the new path change persistent data?
- Can the old path understand data produced by the new path?
- Does the new path call external systems differently?
- Can requests be safely processed by both versions during the transition?
If the answer exposes incompatibility, the rollout needs a migration strategy in addition to a flag. That may involve backward-compatible data changes, dual-reading during a transition, or a deliberate recovery procedure.
A feature flag can reduce exposure. It is not a substitute for designing reversible changes.
Give every temporary flag a lifecycle
A release flag usually passes through a sequence like this:
created -> deployed off -> limited exposure -> broad exposure -> permanent choice -> removedThe last step is easy to neglect because the feature appears finished once everyone receives the new behavior. But leaving the flag behind preserves obsolete complexity.
After a transition is complete, the code may still contain:
if old_transition_flag:
new behavior
else:
behavior nobody uses anymoreFuture developers must still understand both branches. Tests may still cover both. The flag remains another piece of configuration that can be changed accidentally.
Treat cleanup as part of completing the feature. A useful flag record includes:
- why the flag exists;
- who owns the decision;
- what
on,off, and evaluation failure mean; - the condition that makes the flag removable;
- an expected cleanup point.
The cleanup point does not need to be a perfect calendar deadline. It can be an event such as “remove after the new checkout has served all traffic for two weeks without rollback.” What matters is that removal is an explicit outcome rather than a hope.
Distinguish temporary release flags from lasting configuration
Not every runtime choice should be removed.
A setting such as a customer-selected language, an administrator-controlled retention period, or a permanent capability purchased by a customer has a continuing business meaning. That is configuration or product state, even if the implementation resembles a feature flag.
A release flag answers a different kind of question:
Which implementation should handle this transition right now?
Confusing the two creates lifecycle problems. A team may delete a setting that was actually part of the product contract, or keep a temporary implementation branch forever because it has been labelled configuration.
Ask whether both states are expected to remain meaningful after the rollout is complete. If yes, model the choice as durable domain or configuration state. If no, treat it as temporary transition machinery and plan to remove it.
Keep flag names about decisions, not implementation accidents
A flag name should communicate the behavior being selected. Names such as new-checkout or use-revised-invoice-rules are easier to reason about than names tied to a ticket number or a developer’s experiment.
Avoid negative names when they make conditions difficult to read:
if not flags.enabled("disable-new-checkout"):The reader must mentally invert both the condition and the flag name. A positive decision is usually clearer:
if flags.enabled("new-checkout"):Names also matter operationally. When an incident occurs, someone unfamiliar with the implementation may need to decide whether changing a flag is appropriate. A name that describes the controlled behavior reduces ambiguity.
Watch for flag debt
A few well-managed flags can make releases easier to control. Many unmanaged flags create flag debt: accumulated conditional behavior that makes the system harder to understand and change.
Warning signs include:
- flags with no known owner;
- flags that have been permanently on or off for months;
- nested conditions involving several flags;
- the same flag checked in many unrelated modules;
- tests that require large matrices of flag values;
- flags whose effect nobody can explain confidently.
The remedy is not necessarily to stop using flags. It is to make their temporary nature visible. Teams can review old flags periodically, attach cleanup work to the original change, or track lifecycle metadata in whatever system manages flags.
The engineering goal is to preserve the benefit of controlled rollout without turning past releases into permanent branches in current code.
Know when a feature flag is unnecessary
A flag adds another runtime state, so it should solve a real problem.
A straightforward deployment may be simpler when a change is small, easily reversible through normal deployment, and does not need gradual or selective exposure. Adding a flag to every change can create more operational and testing work than the release requires.
Flags are most useful when you need a capability such as:
- separating deployment from user exposure;
- enabling a change for a limited audience;
- increasing exposure gradually;
- coordinating a multi-step transition;
- quickly stopping future use of a new path when that fallback is genuinely safe.
Use the smallest control mechanism that fits the risk and rollout needs of the change.
Conclusion
A feature flag is best understood as temporary control over a software transition. It lets a team deploy code independently from deciding who receives the new behavior, but that flexibility introduces another state the system must handle correctly.
Keep the flag decision near the behavior it controls. Define failure and fallback behavior deliberately. Test paths that actually interact. Do not assume turning a flag off reverses persistent or external side effects. Most importantly, give temporary flags an explicit path to removal.
Used this way, feature flags help manage change without making every past release a permanent part of the system’s complexity.