<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Featureflip Blog</title><description>Notes on feature flags, releases, and engineering from the Featureflip team.</description><link>https://featureflip.io/</link><item><title>Feature Flag Cleanup: A Playbook for Paying Down Flag Debt</title><link>https://featureflip.io/blog/feature-flag-cleanup/</link><guid isPermaLink="true">https://featureflip.io/blog/feature-flag-cleanup/</guid><description>73% of feature flags are never removed — one reused bit cost Knight Capital $460M. The four-step playbook to clean up flag debt safely.</description><pubDate>Mon, 27 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The most expensive feature flag in history was a single bit on one server, repurposed in 2012 because Knight Capital had run out of bits to use. The code path under the deprecated flag had been dead for eight years — until that one server executed it again, and the company lost $460 million in 45 minutes.&lt;/p&gt;
&lt;p&gt;Most teams aren&apos;t a brokerage running an automated order-routing system. But the underlying problem is universal: feature flags are easy to create, awkward to delete, and almost never cleaned up on schedule. Industry data puts the share of flags that never get removed at 73%, with the average enterprise application carrying more than 200 active flags and 60% of them stale beyond 90 days (&lt;a href=&quot;https://flagshark.com/blog/feature-flag-graveyard-73-percent-never-removed/&quot;&gt;FlagShark&lt;/a&gt;, 2025).&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key Takeaways&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;73% of feature flags are never removed; engineers in flag-heavy codebases lose 3–5 hours per week navigating them (&lt;a href=&quot;https://flagshark.com/blog/feature-flag-graveyard-73-percent-never-removed/&quot;&gt;FlagShark&lt;/a&gt;, 2025).&lt;/li&gt;
&lt;li&gt;The fix is a four-step loop (&lt;strong&gt;detect, triage, remove, prevent&lt;/strong&gt;), not a one-off purge.&lt;/li&gt;
&lt;li&gt;The two-PR removal pattern (delete the flag check first, dead branch second) is the specific procedure that prevents Knight Capital-class incidents.&lt;/li&gt;
&lt;li&gt;Governance at flag &lt;em&gt;creation&lt;/em&gt; time (naming conventions, owners, expiration dates) does more than any cleanup sprint.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;Why feature flag debt accumulates&lt;/h2&gt;
&lt;p&gt;Creating a flag takes seconds. Removing one takes a code review, a deploy, and someone willing to verify nothing downstream depended on the dead branch. There&apos;s no automated reminder, no compiler error, and no test that fails when a flag goes stale. So flags accumulate the way unused CSS classes accumulate: silently, and faster than anyone expects.&lt;/p&gt;
&lt;figure&gt;
  
    Active feature flag count over 24 months for a typical enterprise application
    A line chart showing total active flags growing from 0 to 210 over 24 months, with the stale-beyond-90-days portion growing from 0 to 126 (about 60% of the total) by month 24.
    
      
      
      0
      6
      12
      18
      24
      Months since flag system adopted
      0
      50
      100
      150
      200
      Active flags
      
      
      
      
      210 total
      126 stale
      
        
        Total active flags
        
        Stale &amp;gt; 90 days
      
    
  
  &lt;figcaption&gt;Modeled from FlagShark 2025 industry data — 60% of an average enterprise&apos;s 200+ flags are stale beyond 90 days.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The cost compounds in three places. Engineers spend 3–5 hours per week navigating flag-related conditional branches in code review and debugging. Pull request reviews take roughly 60% longer when reviewers have to mentally trace flag interactions. And incident resolution slows by about 40% in flag-heavy codebases, because the runbook now has to consider which paths are gated by which toggles (&lt;a href=&quot;https://flagshark.com/blog/feature-flag-graveyard-73-percent-never-removed/&quot;&gt;FlagShark&lt;/a&gt;, 2025). For a 50-engineer team, the lost productivity adds up to roughly $520,000 a year. That&apos;s enough to hire three or four senior engineers.&lt;/p&gt;
&lt;p&gt;There&apos;s a quieter cost too: every deprecated flag is a future Knight Capital, sitting under code that still runs but is no longer expected to.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 1 — Detect: find every flag and how stale it is&lt;/h2&gt;
&lt;p&gt;You can&apos;t clean up what you can&apos;t see. Detection has two halves: &lt;strong&gt;inventory&lt;/strong&gt; (which flag keys exist?) and &lt;strong&gt;staleness&lt;/strong&gt; (when was each one last meaningful?). Combine your platform&apos;s &lt;a href=&quot;/docs/concepts/feature-flags/&quot;&gt;flag list&lt;/a&gt; with a code-reference scan and a &lt;code&gt;git log&lt;/code&gt; query, and you have a complete picture in under an hour.&lt;/p&gt;
&lt;h3&gt;The grep one-liner&lt;/h3&gt;
&lt;p&gt;For a small codebase, grep gets you most of the way. Adjust the pattern to whatever evaluation API your SDK uses:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;grep -rn &apos;boolVariation\|isFeatureEnabled\|client\.evaluate&apos; src/ \
  | awk &apos;{print $2}&apos; | sort -u
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This produces a rough catalogue of every flag key referenced in code. Crude, but useful as a sanity check against the platform&apos;s flag list. Anything in code but not in the platform is probably dead. Anything in the platform but not in code is a removal candidate.&lt;/p&gt;
&lt;h3&gt;A small AST-style scanner&lt;/h3&gt;
&lt;p&gt;Once you outgrow grep, a 30-line Python script gives you reference &lt;em&gt;counts&lt;/em&gt; per flag key. Save the platform&apos;s flag keys to a text file, one per line, and run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;#!/usr/bin/env python3
&quot;&quot;&quot;Count feature flag references across a codebase.
Usage: python find-flags.py flags.txt src/&quot;&quot;&quot;
import re, sys
from pathlib import Path

flag_keys = Path(sys.argv[1]).read_text().splitlines()
root = Path(sys.argv[2])
extensions = {&quot;.ts&quot;, &quot;.tsx&quot;, &quot;.js&quot;, &quot;.jsx&quot;, &quot;.py&quot;, &quot;.go&quot;, &quot;.cs&quot;, &quot;.java&quot;, &quot;.rb&quot;}

counts = {k: 0 for k in flag_keys if k.strip()}
for path in root.rglob(&quot;*&quot;):
    if not path.is_file() or path.suffix not in extensions:
        continue
    try:
        text = path.read_text(errors=&quot;ignore&quot;)
    except Exception:
        continue
    for key in counts:
        counts[key] += len(re.findall(rf&apos;[&quot;\&apos;]{re.escape(key)}[&quot;\&apos;]&apos;, text))

for key, n in sorted(counts.items(), key=lambda kv: kv[1]):
    print(f&quot;{n:6d}  {key}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Flags with zero hits are obviously dead. Flags with a single hit usually indicate a stale check that was meant to be removed when rollout completed. The interesting case is one or two hits clustered in a single file: a leftover branch that nobody noticed.&lt;/p&gt;
&lt;h3&gt;Git archaeology for creation dates&lt;/h3&gt;
&lt;p&gt;Reference counts tell you what&apos;s stale. &lt;code&gt;git log&lt;/code&gt; tells you how long it&apos;s been stale:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;while read -r flag; do
  date=$(git log --diff-filter=A -S&quot;$flag&quot; --format=%cs --reverse \
         | head -1)
  printf &quot;%s\t%s\n&quot; &quot;${date:-unknown}&quot; &quot;$flag&quot;
done &amp;lt; flags.txt | sort
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;-S&lt;/code&gt; is git&apos;s &quot;pickaxe&quot;: it finds the commit that introduced (or removed) a string. Combined with &lt;code&gt;--diff-filter=A&lt;/code&gt; and &lt;code&gt;--reverse&lt;/code&gt;, you get the date the flag key first appeared. Cross-reference that with your platform&apos;s &quot;last evaluated&quot; timestamp, and any flag with creation date older than 90 days &lt;em&gt;and&lt;/em&gt; zero recent evaluations is a high-confidence cleanup candidate.&lt;/p&gt;
&lt;p&gt;Code-reference scanners also exist as open-source tools published by some flag platforms, if you&apos;d rather adopt one off the shelf than maintain the script above.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 2 — Triage: not every old flag is removable&lt;/h2&gt;
&lt;p&gt;The easy mistake is treating &quot;old&quot; as a synonym for &quot;removable.&quot; Some flags belong forever. Triage means classifying every flag from Step 1 into one of four buckets &lt;em&gt;before&lt;/em&gt; you touch the code.&lt;/p&gt;
&lt;h3&gt;1. Release flag, fully rolled out&lt;/h3&gt;
&lt;p&gt;The cleanest cleanup target. The &lt;a href=&quot;/docs/concepts/rollout-strategies/&quot;&gt;percentage rollout&lt;/a&gt; finished weeks or months ago, the new code path has been the only one serving traffic since, and the old branch is dead weight. Remove the flag check, then remove the dead branch, then delete the flag from the platform.&lt;/p&gt;
&lt;h3&gt;2. Experiment flag, experiment ended&lt;/h3&gt;
&lt;p&gt;The experiment has a winner. The losing arm is dead code, and the flag is no longer evaluated meaningfully. Remove both the flag check &lt;em&gt;and&lt;/em&gt; the losing arm — leaving the loser in place is how an A/B test silently turns into permanent dead code.&lt;/p&gt;
&lt;h3&gt;3. Permanent flag&lt;/h3&gt;
&lt;p&gt;Kill switches, plan-tier entitlements, regional gates, and per-tenant overrides legitimately stay forever. They&apos;re not flags in the rollout-toggle sense; they&apos;re runtime targeting rules. Don&apos;t delete them. Do verify each one has an explicit owner, a &lt;code&gt;perm-&lt;/code&gt; prefix in the key (so triage doesn&apos;t have to re-evaluate it next quarter), and a sentence of documentation about what it gates.&lt;/p&gt;
&lt;h3&gt;4. Zombie flag&lt;/h3&gt;
&lt;p&gt;Evaluated nowhere — the platform shows zero traffic for 90+ days — but code references still exist. This is the dangerous bucket. The code path under the flag is &lt;em&gt;probably&lt;/em&gt; dead, but it&apos;s been waiting around long enough that nobody currently on the team remembers what it does. Treat zombies like any other production change: confirm with the team that owned the original feature, check error logs for any evidence the path still runs, and only then proceed to removal.&lt;/p&gt;
&lt;p&gt;The Knight Capital incident is what happens when zombies are left in place. The flag bit they reused had been a zombie for eight years.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 3 — Remove: how to delete a flag without breaking production&lt;/h2&gt;
&lt;p&gt;Removing a flag is a production change. Treat it like one. The dangerous removal isn&apos;t the obvious dead branch. It&apos;s the branch that quietly turned out to still be reachable by some code path nobody traced.&lt;/p&gt;
&lt;p&gt;The procedure that handles this safely has five steps:&lt;/p&gt;
&lt;h3&gt;1. Confirm zero unexpected traffic&lt;/h3&gt;
&lt;p&gt;The flag has been evaluated only at the rolled-out variant for at least 30 days. No edge cases, no weird per-user overrides still firing. If your platform shows surprise evaluations, find out why before continuing.&lt;/p&gt;
&lt;h3&gt;2. One flag per pull request&lt;/h3&gt;
&lt;p&gt;Never bundle flag removals. If something goes wrong, you want a clean revert that affects exactly one feature.&lt;/p&gt;
&lt;h3&gt;3. Delete the flag check first, the dead branch second — in two PRs&lt;/h3&gt;
&lt;p&gt;This is the single most important rule in flag cleanup, and the one most teams skip. Don&apos;t do this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// PR that does too much
- if (await client.boolVariation(&apos;checkout-v2&apos;, ctx, false)) {
-   return renderCheckoutV2(user);
- } else {
-   return renderCheckoutV1(user);
- }
- function renderCheckoutV1(user) { /* 200 lines of legacy code */ }
+ return renderCheckoutV2(user);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Do this instead. &lt;strong&gt;PR 1&lt;/strong&gt;: make the code path unconditional, but leave the dead branch in place.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;- if (await client.boolVariation(&apos;checkout-v2&apos;, ctx, false)) {
-   return renderCheckoutV2(user);
- } else {
-   return renderCheckoutV1(user);
- }
+ return renderCheckoutV2(user);
  function renderCheckoutV1(user) { /* still here, unused */ }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Deploy. Watch error rates and traffic for 24 hours. If anything was wrong, the revert is a one-line change and the dead-but-present branch is still there to catch any callers you missed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;PR 2&lt;/strong&gt;: delete the dead branch.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;  return renderCheckoutV2(user);
- function renderCheckoutV1(user) { /* 200 lines of legacy code */ }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you&apos;re confident after PR 1 that nothing else calls &lt;code&gt;renderCheckoutV1&lt;/code&gt;, this is safe. If anything &lt;em&gt;does&lt;/em&gt; call it, your editor&apos;s &quot;find references&quot; already told you so before you opened the PR.&lt;/p&gt;
&lt;p&gt;This two-PR pattern is exactly what would have prevented Knight Capital. Their problem wasn&apos;t the flag itself — it was that the dead branch was still in the deployed binary on one of eight servers when an unrelated change re-activated it.&lt;/p&gt;
&lt;h3&gt;4. Watch error rates and traffic for 24 hours after PR 1&lt;/h3&gt;
&lt;p&gt;Treat flag removal like any deploy. Most cleanup-related incidents surface within hours, not days.&lt;/p&gt;
&lt;h3&gt;5. Then delete the flag from the platform&lt;/h3&gt;
&lt;p&gt;This is the irreversible step. Do it after both PRs have shipped and the system has been stable for a week. Once the flag is gone from the platform, any rollback path that depended on flipping it is closed.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 4 — Prevent: governance that stops the cycle&lt;/h2&gt;
&lt;p&gt;If creation policy doesn&apos;t change, cleanup is a treadmill: you&apos;ll be back here in six months with another 200 flags. Three controls do almost all the work, and none of them require buying anything.&lt;/p&gt;
&lt;h3&gt;Naming conventions that encode lifecycle&lt;/h3&gt;
&lt;p&gt;A flag named &lt;code&gt;new_feature&lt;/code&gt; loses its meaning the day after it ships. A flag named &lt;code&gt;release-billing-annual-plans-2026q2&lt;/code&gt; tells you the type, the owning team, the feature, and the rough timeline — even if every original author has left the company. A reasonable &lt;a href=&quot;/docs/guides/creating-flags/&quot;&gt;naming convention&lt;/a&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;release-billing-annual-plans-2026q2     # rollout, removable
exp-onboarding-progress-bar-v2          # experiment, removable
perm-killswitch-fraud-detection         # permanent, never to be removed
perm-entitlement-pro-plan-features      # permanent, plan tier
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;release-&lt;/code&gt;, &lt;code&gt;exp-&lt;/code&gt;, and &lt;code&gt;perm-&lt;/code&gt; prefixes do most of the work. They let triage immediately separate &quot;should be removed&quot; from &quot;intentionally permanent&quot; without context.&lt;/p&gt;
&lt;h3&gt;Expiration dates set at creation&lt;/h3&gt;
&lt;p&gt;Every flag platform should have a &quot;scheduled review&quot; or expiration field. If yours doesn&apos;t, a calendar reminder works. The bar is low: any defaulted-to-30-days reminder beats no reminder at all. Google&apos;s internal practice is to expire experiment flags after 30 days unless an engineer explicitly renews them with a justification (&lt;a href=&quot;https://www.statsig.com/glossary/feature-flag-naming-convention&quot;&gt;Statsig glossary&lt;/a&gt;). It&apos;s a mechanism worth borrowing whether you&apos;re at Google scale or not.&lt;/p&gt;
&lt;h3&gt;A recurring cleanup cadence&lt;/h3&gt;
&lt;p&gt;A monthly or quarterly &quot;flag debt day&quot; — one engineering hour per person — is more sustainable than a yearly purge. Treat it like dependency upgrades: ignored for a quarter, manageable; ignored for two years, terrifying.&lt;/p&gt;
&lt;figure&gt;
  
    Annual flag-debt cost by engineering team size
    A bar chart showing annual lost productivity from feature flag debt scaling linearly with team size: $104,000 at 10 engineers, $260,000 at 25, $520,000 at 50, and $1,040,000 at 100.
    
      Annual lost productivity ($)
      
      
      $0
      $250k
      $500k
      $750k
      $1M
      
      10 eng
      $104k
      
      25 eng
      $260k
      
      50 eng
      $520k
      
      100 eng
      $1.04M
      Engineering team size
    
  
  &lt;figcaption&gt;Annual cost of sustained flag debt scales linearly with engineering headcount. Source: FlagShark productivity calculator, 2025.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The investment that prevents most of this is genuinely small: an hour at flag creation to set a name, owner, and expiration; an hour a month to triage. Skipping both, year after year, is what produces the curve in the chart above.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The Knight Capital lesson&lt;/h2&gt;
&lt;p&gt;In July 2012, Knight Capital deployed new code for its automated equity order-routing system, called SMARS. The deploy missed one of eight production servers. The new code reused a feature flag bit from a feature called Power Peg, which had been deprecated in 2003 and never removed from the binary (&lt;a href=&quot;https://www.henricodolfing.ch/en/case-study-4-the-440-million-software-error-at-knight-capital/&quot;&gt;Henrico Dolfing case study&lt;/a&gt;, 2019).&lt;/p&gt;
&lt;p&gt;When the flag was set in production, seven of eight servers ran the new logic. The eighth server, still carrying the old binary, ran Power Peg. Power Peg&apos;s order-fulfillment reporting had been altered after deprecation, so completed orders were never marked as completed — and the system kept sending more. In 45 minutes, Knight bought $7 billion of stock it couldn&apos;t pay for, lost roughly $460 million in the unwind, and was acquired by Getco three months later (&lt;a href=&quot;https://en.wikipedia.org/wiki/Knight_Capital_Group&quot;&gt;Knight Capital Group&lt;/a&gt;, Wikipedia).&lt;/p&gt;
&lt;p&gt;The framing most retellings reach for is &quot;feature flags are dangerous.&quot; That&apos;s the wrong takeaway. The flag was perfectly safe in 2003, when the Power Peg feature was live. It became dangerous only because the flag was retired without removing the code path it gated, and stayed dangerous for nine years until something else turned the bit back on.&lt;/p&gt;
&lt;p&gt;The lesson is narrower and more useful: &lt;strong&gt;deferred cleanup compounds&lt;/strong&gt;. Every deprecated flag still in your binary is a future incident waiting for an unrelated change to step on it. The two-PR removal procedure in Step 3 isn&apos;t bureaucracy. It&apos;s the specific protocol that prevents the dead branch from outliving the flag.&lt;/p&gt;
&lt;p&gt;For a deeper view of the lifecycle this fits into, see the broader question of &lt;a href=&quot;/blog/feature-flags-vs-environment-variables/&quot;&gt;when feature flags belong in your stack&lt;/a&gt;.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Frequently asked questions&lt;/h2&gt;
&lt;h3&gt;What percentage of feature flags are never removed?&lt;/h3&gt;
&lt;p&gt;Industry data puts the share at 73%, with the average enterprise application carrying more than 200 active flags and roughly 60% of them stale beyond 90 days (&lt;a href=&quot;https://flagshark.com/blog/feature-flag-graveyard-73-percent-never-removed/&quot;&gt;FlagShark&lt;/a&gt;, 2025). The accumulation creates measurable productivity costs: engineers spend 3–5 hours a week navigating flag-heavy code in review and debugging.&lt;/p&gt;
&lt;h3&gt;How do you find unused feature flags in your code?&lt;/h3&gt;
&lt;p&gt;Three tools, in increasing power. &lt;code&gt;grep&lt;/code&gt; for a quick reference catalogue. A small AST-style script (about 30 lines of Python) that takes your platform&apos;s flag-key list and counts references per flag. And &lt;code&gt;git log --diff-filter=A -S&amp;lt;flag&amp;gt;&lt;/code&gt; to find when each flag was first introduced, so you can spot keys that have been stale for years. Cross-reference with your platform&apos;s &quot;last evaluated&quot; timestamp.&lt;/p&gt;
&lt;h3&gt;How long should a feature flag live?&lt;/h3&gt;
&lt;p&gt;Most rollout flags should be retired within weeks of hitting 100%. Experiment flags should live for the duration of the experiment plus a two-week stabilization window. Permanent flags (kill switches, plan-tier entitlements, regional gates) live indefinitely &lt;em&gt;if&lt;/em&gt; they&apos;re documented as permanent and assigned an owner. Set an expiration or review date when the flag is created, not after.&lt;/p&gt;
&lt;h3&gt;Is it safe to just delete an old flag from the platform?&lt;/h3&gt;
&lt;p&gt;Not on its own. Deleting a flag from the platform without first removing the flag check from code is how you produce silent behavior changes. The SDK will start serving the default value, which may or may not match the rolled-out behavior. Delete the flag check from code first (in two PRs, as in Step 3), then delete the flag from the platform once you&apos;ve verified the system is stable.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The shorter version&lt;/h2&gt;
&lt;p&gt;Cleanup is a four-step loop, not a one-off effort. Detect every flag and how stale it is. Triage into rollout, experiment, permanent, or zombie. Remove rollout and experiment flags in two PRs — flag check first, dead branch second. And prevent the next round by setting a name, owner, and expiration when the flag is created.&lt;/p&gt;
&lt;p&gt;The Knight Capital story is the worst-case demonstration of why this matters, but it isn&apos;t the typical case. The typical case is quieter: 200 flags accumulated over two years, three to five hours a week of mental tax per engineer, slower reviews, slower incident response, and a codebase that&apos;s slightly harder to reason about than it should be. Every flag you create is a flag someone has to remember to delete.&lt;/p&gt;
&lt;p&gt;Featureflip is built around this lifecycle — flag keys, owners, and last-evaluated timestamps are first-class in the dashboard, so naming conventions and stale-flag triage become observable rather than tribal knowledge. If you&apos;d like to see how that plays out in practice, &lt;a href=&quot;https://app.featureflip.io/sign-up&quot;&gt;start with the Solo plan&lt;/a&gt;. It&apos;s free forever for one project.&lt;/p&gt;
</content:encoded><category>cleanup</category><category>debt</category><category>fundamentals</category></item><item><title>Feature Flags vs Environment Variables: A Practical Guide</title><link>https://featureflip.io/blog/feature-flags-vs-environment-variables/</link><guid isPermaLink="true">https://featureflip.io/blog/feature-flags-vs-environment-variables/</guid><description>Env vars configure where the process runs; flags decide what each request sees. Decision rule, comparison table, and 4 production gotchas inside.</description><pubDate>Sat, 25 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Environment variables and feature flags both control application behavior, but they solve different problems. Conflating them leads to fragile systems, awkward deployment workflows, and flags that outlive their usefulness by years. The doctrine of putting config in the environment goes back to &lt;a href=&quot;https://12factor.net/config&quot;&gt;The Twelve-Factor App&lt;/a&gt;; the concept of a feature toggle was formalised by &lt;a href=&quot;https://martinfowler.com/articles/feature-toggles.html&quot;&gt;Martin Fowler and Pete Hodgson&lt;/a&gt;, who split toggles into release, experiment, ops, and permission categories. This post lays out when each tool belongs, and where teams confuse them.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key Takeaways&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Env vars&lt;/strong&gt; configure &lt;em&gt;where&lt;/em&gt; and &lt;em&gt;how&lt;/em&gt; a process runs (database URLs, API keys, log levels). They are static for the life of the process and require a redeploy to change.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Feature flags&lt;/strong&gt; configure &lt;em&gt;what a specific request or user sees&lt;/em&gt;. They are evaluated per-request, support targeting, and change without a deploy.&lt;/li&gt;
&lt;li&gt;The one-line rule: if two users hitting the same running instance could ever need different values, it&apos;s a flag. If not, it&apos;s an env var.&lt;/li&gt;
&lt;li&gt;The most common mistake is &lt;code&gt;ENABLE_X=true&lt;/code&gt; env vars repurposed as poor-man&apos;s flags. Fine until the day you need a 10% rollout or a 2 AM kill switch (&lt;a href=&quot;/blog/feature-flag-cleanup/&quot;&gt;feature flag cleanup playbook&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h2&gt;What each one is&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;An environment variable&lt;/strong&gt; is a named string value set in the process environment at startup and treated as static for the lifetime of that process. It configures where the application connects, what mode it runs in, and what secrets it uses: things that differ between deployment targets, not between users.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A feature flag&lt;/strong&gt; is a named boolean or multi-variant value evaluated at runtime, typically over a remote source of truth, that controls which code path executes for a given request or user. It changes behavior without a redeploy, and can be scoped to a specific segment of traffic.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;When should you use environment variables?&lt;/h2&gt;
&lt;p&gt;Environment variables excel at configuration that is static within an environment, secret, or determined before the process starts. The Twelve-Factor App&apos;s third factor codifies this: anything that varies between deploys (credentials, hosts, per-environment toggles) should live in the process environment, not in source code (&lt;a href=&quot;https://12factor.net/config&quot;&gt;12factor.net&lt;/a&gt;). Env vars are read once at startup and treated as immutable for the process&apos;s lifetime.&lt;/p&gt;
&lt;h3&gt;1. Database connection strings&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;DATABASE_URL&lt;/code&gt; is the canonical example. It points to your Postgres instance, includes credentials, and is different in dev, staging, and production. It never changes while the app is running. Storing it as an env var means it stays out of source code, can be rotated by updating the deployment secret, and doesn&apos;t require any runtime evaluation logic.&lt;/p&gt;
&lt;h3&gt;2. API keys and secrets&lt;/h3&gt;
&lt;p&gt;Third-party service keys (payment processor secrets, object storage credentials, outbound email API keys) are secrets first and configuration second. Env vars compose naturally with secret management systems (Kubernetes secrets, Vault, Doppler) and satisfy security policies that require secrets to be kept out of application code. Evaluating a feature flag to find a secret is the wrong abstraction entirely.&lt;/p&gt;
&lt;h3&gt;3. Third-party service endpoints&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;STRIPE_API_BASE&lt;/code&gt;, &lt;code&gt;OPENAI_API_HOST&lt;/code&gt;, &lt;code&gt;S3_ENDPOINT&lt;/code&gt;. These are environment-level choices. Staging points at sandbox endpoints, production points at live ones. These don&apos;t change per-user and don&apos;t need runtime toggle semantics.&lt;/p&gt;
&lt;h3&gt;4. Build-time and runtime mode flags&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;NODE_ENV=production&lt;/code&gt;, &lt;code&gt;RAILS_ENV=production&lt;/code&gt;, &lt;code&gt;ASPNETCORE_ENVIRONMENT=Production&lt;/code&gt;. These inform the framework itself, not just your code. They affect which config files load, whether debug middleware is enabled, and how assets are bundled. They must be set before the process starts and cannot meaningfully be changed mid-flight.&lt;/p&gt;
&lt;h3&gt;5. Log levels and observability config&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;LOG_LEVEL=warn&lt;/code&gt;, &lt;code&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/code&gt;, &lt;code&gt;SENTRY_DSN&lt;/code&gt;. These affect how the process reports on itself. They are environment-wide, they configure external systems, and they often come from your platform&apos;s secrets store. Routing them through a feature flag system adds a circular dependency (what if the flag system itself fails before logging is configured?).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The common thread:&lt;/strong&gt; env vars are for &lt;em&gt;where&lt;/em&gt; and &lt;em&gt;how&lt;/em&gt; the process runs, not &lt;em&gt;what&lt;/em&gt; it does for a specific user.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;When should you use feature flags?&lt;/h2&gt;
&lt;p&gt;Feature flags shine when the question is: &quot;should this code path execute, for whom, and starting when?&quot; Martin Fowler&apos;s canonical taxonomy splits flags into four categories (&lt;em&gt;release&lt;/em&gt;, &lt;em&gt;experiment&lt;/em&gt;, &lt;em&gt;ops&lt;/em&gt;, and &lt;em&gt;permission&lt;/em&gt; toggles), each with different lifetimes and ownership (&lt;a href=&quot;https://martinfowler.com/articles/feature-toggles.html&quot;&gt;martinfowler.com&lt;/a&gt;). Three of those four are impossible to express cleanly as env vars, because they need per-request, per-user, or runtime-mutable evaluation.&lt;/p&gt;
&lt;h3&gt;1. Gradual rollouts&lt;/h3&gt;
&lt;p&gt;You&apos;ve merged a rework of your checkout flow. You want to expose it to 5% of users, monitor error rates and conversion, then ramp to 100% if the metrics look healthy, all without a second deployment. A feature flag does this; an env var does not.&lt;/p&gt;
&lt;h3&gt;2. Kill switches&lt;/h3&gt;
&lt;p&gt;Certain features carry operational risk: a new third-party integration, a resource-intensive background job, a new payment provider. A kill switch flag lets an on-call engineer disable it in seconds without touching infrastructure. An env var change requires a process restart (typically a rolling deploy that takes minutes, not seconds) and introduces its own risk.&lt;/p&gt;
&lt;h3&gt;3. A/B tests and experiments&lt;/h3&gt;
&lt;p&gt;Testing two button labels, two pricing page layouts, or two recommendation algorithms requires serving different variants to different users within the same deployed build. That&apos;s a flag, specifically a multivariate flag, not a config value.&lt;/p&gt;
&lt;h3&gt;4. Per-user and per-segment targeting&lt;/h3&gt;
&lt;p&gt;Beta programs, internal dogfooding, enterprise tenant overrides, and geographic feature launches all require the same question: &quot;should user X get behavior Y?&quot; Env vars have no concept of a user context. Flags evaluate against user attributes, segment membership, or a deterministic hash of the user ID.&lt;/p&gt;
&lt;h3&gt;5. Paywall and plan-based variations&lt;/h3&gt;
&lt;p&gt;Showing premium features to paid users, gating beta features behind an opt-in, or launching a new UI only for enterprise accounts: these are targeting decisions made at evaluation time, per-request. Flags model this directly. A build-time config cannot.&lt;/p&gt;
&lt;h3&gt;6. Dark launches&lt;/h3&gt;
&lt;p&gt;You want to call the new code path in production, observe its behavior, and collect metrics, but not yet show its output to users. Wrap it in a flag that&apos;s off for everyone, deploy, then turn it on for internal users only. This is impossible to express cleanly as an env var.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The common thread:&lt;/strong&gt; flags are for &lt;em&gt;what a specific request or user experiences&lt;/em&gt;, and for control you need to exert without touching deployment.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Side-by-side comparison&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Environment variable&lt;/th&gt;
&lt;th&gt;Feature flag&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Changes at runtime&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No, requires process restart or redeploy&lt;/td&gt;
&lt;td&gt;Yes, evaluated on every request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Per-user targeting&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No, process-wide value&lt;/td&gt;
&lt;td&gt;Yes, evaluates against user context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Audit log&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No built-in history&lt;/td&gt;
&lt;td&gt;Yes, changes tracked with timestamp and actor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Restart required&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes, set at process startup&lt;/td&gt;
&lt;td&gt;No, changes propagate to running instances&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Granularity&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Environment (dev / staging / prod)&lt;/td&gt;
&lt;td&gt;Per-user, per-segment, percentage-based&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Typical lifetime&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Indefinite (rotated when credentials change)&lt;/td&gt;
&lt;td&gt;Weeks to months (should be cleaned up after rollout)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The table understates one dimension: &lt;strong&gt;change-propagation latency&lt;/strong&gt;. The order-of-magnitude gap between deploying a new env var and flipping a flag is what makes flags suitable for kill switches and incident response.&lt;/p&gt;
&lt;figure&gt;
  
    Change propagation latency: code change vs env var vs feature flag
    A horizontal bar chart on a log scale comparing how long a configuration change takes to take effect. A code change with a full deploy lands between 10 and 60 minutes. An env var change with a rolling restart lands between 1 and 10 minutes. A feature flag flip with an SDK streaming connection lands between 1 and 5 seconds.
    
      Change propagation: how fast does a switch take effect?
      Code change + deploy
      
      10–60 min
      Env var (rolling deploy)
      
      1–10 min
      Feature flag flip
      
      1–5 sec
      
      
      
      
      
      
      1s
      10s
      1 min
      10 min
      1 hour
      Time for change to take effect (log scale)
    
  
  &lt;figcaption&gt;Log scale. Flag SDKs propagate via streaming or short polling intervals; env var changes need a process restart (typically a rolling deploy); pure code changes also pay a CI build. That 100×–1000× gap is why kill switches and gradual rollouts belong in a flag system.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;hr /&gt;
&lt;h2&gt;Decision flowchart: which one do I reach for?&lt;/h2&gt;
&lt;figure&gt;
  
    Env var vs feature flag decision flowchart
    A three-question decision tree. If the value is a secret or static per deployment target, use an environment variable. If it requires runtime change, per-user targeting, or temporary rollout control, use a feature flag. Otherwise ask whether two users in the same instance could ever need different values: yes is a flag, no is an env var.
    
    
      What are you configuring?
      
      
      Is it a secret, credential,
      or static per deployment target?
      
      Yes
      
      No
      
      ENV VAR
      
      Per-user targeting, runtime change,
      or temporary rollout toggle?
      
      Yes
      
      No
      
      FLAG
      
      Could two users in the same instance
      ever need different values?
      
      No
      
      Yes
      
      ENV VAR
      
      FLAG
    
  
  &lt;figcaption&gt;If you&apos;d ever want two users in the same running instance to see different values, it&apos;s a flag. Otherwise it&apos;s an env var.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;If you prefer it as text:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Secret, credential, or static per deployment&lt;/strong&gt; (API keys, &lt;code&gt;DATABASE_URL&lt;/code&gt;, &lt;code&gt;LOG_LEVEL&lt;/code&gt;) → environment variable.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Runtime change, per-user targeting, or temporary rollout&lt;/strong&gt; (kill switches, A/B tests, beta gates) → feature flag.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Still ambiguous?&lt;/strong&gt; Ask whether two users hitting the same running instance could ever need different values. If yes → flag. If no → env var.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;Common pitfalls&lt;/h2&gt;
&lt;h3&gt;Using env vars as poor-man&apos;s feature flags&lt;/h3&gt;
&lt;p&gt;The most frequent mistake: an engineer creates &lt;code&gt;ENABLE_NEW_CHECKOUT=true&lt;/code&gt; in the environment to toggle a feature. This works until you need to roll it out to 10% of users, or turn it off at 2 AM without waking up the DevOps rotation. At that point the team discovers they&apos;ve built a deployment-gated toggle instead of a runtime one. Changing it requires a redeployment and process restart, not a flag flip, and migrating the semantics while the feature is already in production is uncomfortable. The kill-switch case has real-world stakes: the &lt;a href=&quot;/blog/feature-flag-cleanup/&quot;&gt;Knight Capital incident&lt;/a&gt; — $460M lost in 45 minutes when a deprecated flag&apos;s bit was reused — is the textbook example of a toggle that should have been a runtime kill switch backed by a cleanup workflow.&lt;/p&gt;
&lt;h3&gt;Using flags for things that never change&lt;/h3&gt;
&lt;p&gt;The inverse mistake is routing static infrastructure config through a flag system. &lt;code&gt;POSTGRES_MAX_CONNECTIONS&lt;/code&gt; or &lt;code&gt;REDIS_CLUSTER_HOST&lt;/code&gt; do not need audit logs or gradual rollout semantics. Adding them to a flag system increases the surface area for misconfiguration and creates a dependency on the flag service during startup, which is exactly when you want the fewest external dependencies.&lt;/p&gt;
&lt;h3&gt;Stale flags rotting in the codebase&lt;/h3&gt;
&lt;p&gt;Feature flags are temporary by design, but in practice they rot. Industry data puts the share of flags that never get removed at 73%, with the average enterprise application carrying 200+ active flags and 60% stale beyond 90 days (&lt;a href=&quot;https://flagshark.com/blog/feature-flag-graveyard-73-percent-never-removed/&quot;&gt;FlagShark&lt;/a&gt;, 2025). A flag for a rollout that completed eight months ago is dead code wrapped in an &lt;code&gt;if&lt;/code&gt; statement, and nobody is sure whether it&apos;s safe to remove. Build retirement into your workflow: when a flag hits 100% rollout and metrics are stable, schedule the cleanup. The &lt;a href=&quot;/blog/feature-flag-cleanup/&quot;&gt;four-step cleanup playbook&lt;/a&gt; (detect, triage, remove, prevent) covers the specific procedure.&lt;/p&gt;
&lt;h3&gt;Leaking configuration concerns across layers&lt;/h3&gt;
&lt;p&gt;Checking &lt;code&gt;process.env.ENABLE_NEW_CHECKOUT&lt;/code&gt; deep in a service module creates an implicit coupling between deployment config and business logic. Flag evaluation, by contrast, passes a user context explicitly. This makes the behavior testable (inject a flag client that returns the value you want), auditable (the flag system records who changed it), and decoupled from deployment.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;What flag evaluation looks like in production code&lt;/h2&gt;
&lt;p&gt;Here&apos;s a concrete example using Featureflip&apos;s Node.js SDK:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { FeatureflipClient } from &quot;@featureflip/node-sdk&quot;;

const client = await FeatureflipClient.create({
  sdkKey: process.env.FEATUREFLIP_SDK_KEY!,
});

const currentUser = { id: &quot;user-123&quot; }; // from your auth layer

const checkoutV2Enabled = client.boolVariation(
  &quot;checkout-v2&quot;,
  { user_id: currentUser.id },
  false, // default if evaluation fails or flag is missing
);

if (checkoutV2Enabled) {
  // new flow
} else {
  // old flow
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The SDK key itself is an env var: it&apos;s a credential scoped to an environment. The flag evaluation is runtime, per-user, and falls back gracefully to &lt;code&gt;false&lt;/code&gt; if the flag service is unreachable. The SDK key never changes between requests; the flag result does. For more on how Featureflip models targeting, segments, and rollout percentages, see the &lt;a href=&quot;/docs/concepts/rollout-strategies/&quot;&gt;rollout strategies&lt;/a&gt; and &lt;a href=&quot;/docs/concepts/environments/&quot;&gt;environments&lt;/a&gt; docs.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Frequently asked questions&lt;/h2&gt;
&lt;h3&gt;Can I use environment variables for A/B testing?&lt;/h3&gt;
&lt;p&gt;Not effectively. A/B testing requires serving different variants to different users within the same running process, based on a stable hash of the user ID or a targeting rule. Environment variables are process-wide (every request sees the same value), so you can&apos;t split traffic without running multiple deployments. Use a multivariate feature flag instead.&lt;/p&gt;
&lt;h3&gt;How long should a feature flag live in the codebase?&lt;/h3&gt;
&lt;p&gt;Most rollout flags should be retired within weeks of hitting 100%. Permanent kill switches and entitlement flags (paywall, plan tier) live indefinitely by design. The trap is rollout flags that quietly become permanent: 73% of flags are never removed in surveys of mature flag installations (&lt;a href=&quot;https://flagshark.com/blog/feature-flag-graveyard-73-percent-never-removed/&quot;&gt;FlagShark&lt;/a&gt;, 2025). Treat retirement as part of the rollout, not an afterthought.&lt;/p&gt;
&lt;h3&gt;Are feature flags a security risk?&lt;/h3&gt;
&lt;p&gt;They can be, if misused. Putting secrets in a flag system is the obvious mistake: flag values are typically cached on the client and visible to anyone who inspects the SDK payload. Use environment variables and a secrets manager for credentials. Flags are safe for behavior toggles and targeting rules, which don&apos;t expose sensitive data even if the flag config leaks.&lt;/p&gt;
&lt;h3&gt;What happens if the feature flag service is down?&lt;/h3&gt;
&lt;p&gt;Every reputable SDK is built to degrade safely: evaluations fall back to the default value passed in code, and the SDK retries the connection in the background. That&apos;s why the third argument to &lt;code&gt;boolVariation(...)&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt; in the example above. It&apos;s the value served if the flag service can&apos;t be reached, so your app stays up and the new code path simply doesn&apos;t activate.&lt;/p&gt;
&lt;h3&gt;Should I version-control my feature flag definitions?&lt;/h3&gt;
&lt;p&gt;Most teams keep flag &lt;em&gt;definitions&lt;/em&gt; (key, variations, default) out of source control and manage them through the flag service&apos;s UI or API, because the whole point is changing them without a deploy. What does belong in version control: the flag &lt;em&gt;key&lt;/em&gt; used in code, the default value, and a comment explaining what the flag gates and when it&apos;s expected to be retired.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The one-line rule&lt;/h2&gt;
&lt;p&gt;Configuration that belongs to the &lt;strong&gt;process&lt;/strong&gt; goes in env vars. Configuration that belongs to the &lt;strong&gt;request or user&lt;/strong&gt; goes in flags. Env vars answer &quot;where does this service connect?&quot;; flags answer &quot;what does this user see?&quot;&lt;/p&gt;
&lt;p&gt;If you remember one heuristic from this post: ask whether you&apos;d ever want this value to differ between two users hitting the same running instance. If yes, it&apos;s a flag. If no, it&apos;s an env var.&lt;/p&gt;
&lt;p&gt;For a deeper reference on how Featureflip models flags, targeting rules, and evaluation context, see &lt;a href=&quot;/docs/concepts/feature-flags/&quot;&gt;Feature flags overview&lt;/a&gt;.&lt;/p&gt;
</content:encoded><category>config</category><category>fundamentals</category></item></channel></rss>