← Journal
29 Jul 2026 · 10 min read

Your feature flags are a second codebase

Feature flags let you decouple deploy from release. They also let you accumulate a parallel codebase of conditional branches that nobody tests, nobody documents, and nobody removes. Here is what happens when flags become infrastructure, and how to stop it before your test suite is testing code paths that will never run in production.

I like feature flags. I have recommended them to every startup I have worked with. The ability to decouple deploy from release is one of the few genuinely useful patterns that the DevOps movement gave us, and it has saved companies I worked with from shipping broken code to all their users at once.

I also have a rule about feature flags: every flag you add is a loan. The principal is the complexity it introduces. The interest is the testing and maintenance cost of maintaining two code paths. And like every loan, it compounds. The difference is that nobody sets a repayment schedule for feature flags, so the debt accumulates silently until someone tries to test the application and discovers they are maintaining a codebase that has 200 flags, 400 conditional branches, and a test suite that covers 60% of the possible combinations.

At that point, your feature flags are not a deploy strategy. They are a second codebase, running inside the first one, with no documentation, no ownership, and no cleanup plan.

The startup that had 247 flags

I audited a SaaS company in Tallinn last year. Forty engineers, a Rails monolith, 247 active feature flags in their flag provider. I asked the platform lead when the last time was they had removed a flag. He thought about it for a while and said he could not remember.

I asked him to show me the flags. We exported the list. Here is what it looked like:

flag_v2_checkout              - created 2024-03-14 - last evaluated: 2 days ago
flag_new_auth_flow            - created 2024-01-22 - last evaluated: never
flag_payments_redesign        - created 2023-11-08 - last evaluated: 3 months ago
flag_q3_landing_page          - created 2023-07-01 - last evaluated: 8 months ago
flag_temp_disable_refunds     - created 2024-06-15 - last evaluated: 5 months ago
flag_use_new_logger           - created 2024-02-10 - last evaluated: 2 days ago
...

247 flags. I went through them with the team. Here is what we found:

  • 89 flags were permanently on for all users. The feature had shipped, the flag was serving 100% of traffic, and nobody had removed it. These were dead branches that ran every time but had been forgotten.
  • 43 flags were permanently off. The feature had been abandoned, the flag was serving 0% of traffic, and the code behind it was still in the repository, still being compiled, still being loaded into memory.
  • 31 flags were created for A/B tests that had concluded. The test result was known. The flag was still in the code.
  • 22 flags had no owner. Nobody on the current team knew who created them or what they did.
  • 18 flags were being used as configuration, not as flags. Things like flag_enable_logging that should have been an environment variable.
  • 14 flags were duplicates of each other, created by different engineers who did not know the other flag existed.

That left 30 flags that were genuinely active, serving partial traffic, with a clear purpose and a known owner. 30 out of 247. The rest were debt.

The combinatorial explosion you are not testing

The problem with 247 flags is not the number. It is the interactions. If you have 247 boolean flags, you have 2 to the power of 247 possible states. That number is larger than the number of atoms in the observable universe. You are not testing all of them.

But you do not need to test all of them. You need to test the ones that can actually occur in production, and that is the problem. With 247 flags, many of which are permanently on or permanently off, the number of realistic states is much smaller. But nobody knows which flags interact with which, because nobody tracks flag interactions.

I found this out the hard way at the Tallinn company. We had two flags that were both permanently on: flag_v2_checkout and flag_new_auth_flow. Both had been at 100% for months. The checkout flow and the auth flow shared a session validation function. When we turned off flag_v2_checkout to clean it up, the checkout reverted to the v1 code path, which called the session validation function with a different signature than the new auth flow expected. The application crashed for every user on the next request.

The flag had been on for so long that the v1 code path had silently broken. A dependency had changed. The old code was never run, so the breakage was never caught. The flag was protecting the application from its own rot.

This is the trap. Flags that stay on forever do not just add complexity. They create dead code paths that are never exercised and therefore never verified. When you finally remove the flag, you are deploying untested code to production. You have turned a cleanup task into a release risk.

Flags are not configuration

The 18 flags being used as configuration at the Tallinn company were the easiest to fix, and they illustrate a confusion I see everywhere. A feature flag answers the question: should this code path be active for this user right now? Configuration answers the question: what value should this parameter take?

These are different questions with different lifecycles. A feature flag is temporary. It has a creation date and a removal date. Configuration is permanent. It changes when the system’s requirements change, not when a feature launches.

When you use a flag for configuration, you create a flag that can never be removed, because removing it means changing a configuration value, not retiring a feature. The flag becomes permanent infrastructure, and permanent flags are the ones that accumulate the most debt.

# This is a flag. It has a lifecycle.
if feature_flags.is_enabled("v2_checkout", user_id):
    return checkout_v2(request)
else:
    return checkout_v1(request)

# This is configuration pretending to be a flag.
# It will never be removed because it is not a feature.
if feature_flags.is_enabled("enable_verbose_logging"):
    logger.setLevel("DEBUG")
else:
    logger.setLevel("INFO")

The second flag should be a config value. It should live in a YAML file or an environment variable. It should not be in the flag provider, it should not have targeting rules, and it should not show up in the flag dashboard next to actual feature flags where it confuses every new engineer who opens the panel.

What a flag lifecycle actually looks like

Every flag should have a lifecycle. I enforce this with a simple rule: no flag is merged without a removal plan attached to the pull request. The removal plan answers three questions.

When will this flag be removed? Not “after the feature ships.” A date. “This flag will be removed by 2026-08-15.” If the team is not confident enough to set a date, the flag is not ready to be merged.

Who is responsible for removing it? A name, not a team. “Aayan is responsible for removing this flag.” If Aayan leaves the company, the flag transfers to his replacement. If nobody picks it up, the flag is removed by the platform team on the date, regardless of whether the feature is ready.

What is the removal condition? “The flag will be removed when checkout v2 reaches 100% of traffic for two consecutive weeks with no rollback.” This is the trigger. It is measurable, it is specific, and it does not require a meeting to evaluate.

I keep this information in a file in the repository, not in the flag provider. The flag provider is operational. The repository is the source of truth for the codebase, and flags are part of the codebase.

# flags.yaml
- name: v2_checkout
  created: 2026-07-01
  owner: aayan@digitalaultis.com
  removal_date: 2026-08-15
  removal_condition: "100% traffic for 2 weeks with no rollback"
  status: active

A CI job checks this file every week. If a flag has passed its removal date, the job fails. The build goes red. The team sees it in their standup. The flag gets removed or the removal date gets updated with a reason. Either way, the debt is visible.

The cleanup that nobody wants to do

Removing flags is not fun. It is the engineering equivalent of paying off a loan. You do not get a new feature out of it. You do not get a demo for the stakeholder meeting. You get less code, fewer branches, and a test suite that covers more of what actually runs.

But the cleanup is where the value is, and the cleanup is where I see teams fail. The Tallinn company had 247 flags and a process for creating new ones. They had zero flags with a removal date. The process for creating flags was well-defined. The process for removing them did not exist.

I ran the cleanup with them over two weeks. Here is what we did.

First, we identified the 89 permanently-on flags. For each one, we removed the flag check and the dead branch. This was straightforward in most cases: delete the if, keep the new code path, remove the old code path. In 7 cases, the old code path had rotted and we caught the breakage in CI before it reached production. Those 7 flags had been on for an average of 9 months. The dead branches behind them had accumulated changes that were incompatible with the current codebase.

# Before cleanup
if feature_flags.is_enabled("v2_checkout", user_id):
    return checkout_v2(request)
else:
    return checkout_v1(request)  # dead for 9 months, broken

# After cleanup
return checkout_v2(request)

Second, we removed the 43 permanently-off flags. We deleted the flag check and the dead branch behind it. This was safe because the code was never running. It was also the most satisfying part, because we deleted significant amounts of code. One flag had been off for 14 months and the feature behind it was 2,300 lines of abandoned payment integration work.

Third, we converted the 18 configuration flags to actual configuration. We moved them to a config file, removed them from the flag provider, and updated the code to read from config instead of flags.

Fourth, we merged the 14 duplicate flags. For each pair, we picked one, confirmed it served the same purpose, and removed the other.

After two weeks, we had 30 active flags. The codebase was 11,000 lines smaller. The test suite ran 14% faster because it was no longer testing dead branches. And the flag dashboard, which had been a wall of 247 toggles that nobody understood, was a clean list of 30 flags with owners, removal dates, and clear purposes.

When to use a flag and when to just deploy

Not every change needs a flag. I have seen teams wrap every change in a flag because they read a blog post about trunk-based development and concluded that flags replace branches. They do not. Flags replace risky deploys. If the change is not risky, the flag is overhead.

I use flags for three things:

New features that need a controlled rollout. The flag lets you deploy the code and then turn it on for 1% of users, watch the metrics, and expand. This is the canonical use case, and it is the one that justifies the complexity.

Changes that might need to be reverted quickly. If you are changing a payment flow and you are not sure the new flow works with every payment provider, a flag lets you turn it off without redeploying. This is the kill switch pattern, and it is valuable for high-risk changes.

Experiments. A/B tests, beta access, geographic rollouts. The flag is the experiment. When the experiment concludes, the flag is removed.

Everything else gets a deploy. A bug fix does not need a flag. A dependency upgrade does not need a flag. A UI tweak does not need a flag. If you are adding a flag to a change that you would confidently deploy without a flag, you are adding debt for no benefit.

The flag review

The cleanup at the Tallinn company was a one-time effort. The thing that prevented the debt from returning was a process change: a monthly flag review.

Once a month, the engineering team spends 30 minutes reviewing the flag list. Every flag that has been at 100% for more than two weeks gets a removal date. Every flag that has been at 0% for more than two weeks gets deleted. Every flag that has no owner gets an owner or gets deleted.

30 minutes a month. That is the maintenance cost. The Tallinn company went from 247 flags to 30, and six months later they had 34. The flag count stayed stable because the review caught new flags before they became permanent.

The review is not a meeting. It is a script that runs against the flag provider, generates a report, and posts it to Slack. The team reviews the report asynchronously. If nobody objects to removing a flag, it gets removed. The 30 minutes is the total time spent across the team, not a synchronous meeting.

#!/bin/bash
# flag_review.sh - run monthly
# Outputs flags that are candidates for removal

echo "=== Flags at 100% for >2 weeks (remove the flag, keep the code) ==="
flag-provider export --format json | jq -r '
  .flags[] |
  select(.serving == 100 and (.last_changed | fromdate < now - 1209600)) |
  "\(.name) - on since \(.last_changed)"'

echo "=== Flags at 0% for >2 weeks (remove the flag and the dead code) ==="
flag-provider export --format json | jq -r '
  .flags[] |
  select(.serving == 0 and (.last_changed | fromdate < now - 1209600)) |
  "\(.name) - off since \(.last_changed)"'

echo "=== Flags with no owner ==="
flag-provider export --format json | jq -r '
  .flags[] |
  select(.owner == null or .owner == "") |
  "\(.name) - no owner"'

The script is simple. The discipline is not. The discipline is what separates a codebase with 30 flags from one with 247. The flags do not accumulate because engineers are careless. They accumulate because removing them is invisible work, and invisible work does not get done unless someone makes it visible.

The real cost

The Tallinn company spent two weeks cleaning up 217 flags. That is roughly 320 engineering hours, or about EUR 28,000 at their average engineer rate. They spent EUR 28,000 removing flags that should have been removed as they went.

The cost of not cleaning up was higher. The 7 broken dead branches we found during cleanup would have been production incidents if anyone had toggled those flags. The 11,000 lines of dead code was consuming compile time, increasing image sizes, and making the codebase harder to navigate. The 247 flags in the dashboard were a constant source of confusion for new engineers during onboarding.

And the test suite was lying. It was passing, but it was testing code paths that would never run in production and not testing code paths that would. The confidence the team had in their tests was built on a foundation of dead branches and missing combinations. After the cleanup, the test suite was smaller but it was honest. It tested what ran.

Feature flags are a tool. Like every tool, they have a cost. The cost is not paid when you create the flag. It is paid when you remove it, and if you never remove it, the cost is paid in incidents, in onboarding time, and in the slow erosion of confidence in your own test suite. Every flag you add is a promise to do the cleanup later. Keep the promise, or the debt will collect on its own schedule, not yours.