The first time I shipped a risky change behind a feature flag instead of a deploy, it felt like cheating. The code went to production Tuesday, nobody saw it, and I turned it on for 5% of users on Thursday after watching error rates for two days. If something had gone wrong, fixing it meant flipping a boolean, not rolling back a deploy and re-running CI. That’s the entire pitch for feature flags: they decouple deploying code from releasing it.
Two different problems, one mechanism
Feature flags get used for two distinct purposes that are worth naming separately. Release flags let you merge incomplete work to main safely – the code ships dark, off by default, and gets turned on when it’s ready. Operational flags let you control behavior in production without a deploy – a kill switch for a flaky third-party integration, or a percentage rollout for a risky change. Martin Fowler’s writeup on feature toggles is still the clearest reference on this distinction and the different lifecycles each type needs (martinfowler.com/articles/feature-toggles.html).
A minimal implementation
You don’t need a vendor product to start. A flags table with a percentage column and a simple hash-based bucketing function covers most early needs:
def is_enabled(flag_name, user_id):
flag = flags_cache.get(flag_name)
if not flag or not flag.enabled:
return False
if flag.rollout_percent >= 100:
return True
bucket = hash(f"{flag_name}:{user_id}") % 100
return bucket < flag.rollout_percent
Hashing on flag name plus user ID keeps a given user consistently in or out of the rollout as the percentage climbs, instead of flipping randomly on every request. That consistency matters - users notice when a feature appears and disappears between page loads.
Rolling out gradually, on purpose
A gradual rollout is only useful if you're watching something while it happens. Pick the rollout steps in advance - 5%, 25%, 50%, 100% - and attach a metric and a time window to each step, not just a vibe. "Move to 25% after 24 hours if the error rate hasn't moved and support hasn't flagged anything" is a real gate. "Turn it up when it feels fine" is how a bad rollout reaches 100% of users before anyone notices.
Segment the early percentage toward internal users or a specific cohort when you can - your own team, then a beta group, then everyone. This catches obvious breakage before it reaches a paying customer, without needing a full staging environment that mirrors production traffic. It also means the first bug reports come from people who know how to write a useful one, rather than a confused support ticket from someone who has no idea a rollout is even happening.
Testing both sides of a flag
A flag that's only ever been exercised in the "on" state during development is a flag you haven't actually tested - in production it will spend real time in both states, often for different users simultaneously. Write tests that exercise the flag both ways, not just the new behavior. This matters more as a flag lives longer: the "off" path is old, well-worn code, but the moment a refactor touches shared logic underneath both branches, it's easy to fix the new path and silently break the old one that most users are still on.
Naming flags so they're findable
A flag named flag_2 or test_thing is useless six months later when someone's trying to figure out if it's safe to delete. Use a consistent pattern - area_feature_description, like onboarding_new_wizard_flow or search_fuzzy_matching - so anyone can guess roughly what a flag does from its name alone, without opening the code that reads it. This sounds like a small thing until your flag list has thirty entries and half of them read like variable names generated under deadline pressure, which, realistically, is exactly how most of them got created.
The debt flags accumulate
Every flag you add is a fork in your code that has to be reasoned about until it's removed. A codebase with forty stale flags, half of them at 100% for a year, is worse than no flag system at all - nobody's confident what's actually controlling behavior anymore. Treat "flag at 100% for 30 days" as a trigger to clean up the flag and delete the old code path, not a permanent state. Put an owner and a removal date on every flag when it's created, and review stale flags on a regular cadence, even a quick one once a month.
Where this fits with trunk-based development
Feature flags are what makes trunk-based development survivable for real feature work - without them, incomplete code either blocks a merge or leaks to users. The trunk-based development site has a good breakdown of how flags, small commits, and short-lived branches reinforce each other (trunkbaseddevelopment.com/feature-flags).
You don't need a dedicated flags platform to get the core benefit. A table, a hashing function, and the discipline to delete flags once they've served their purpose will get a five-person team most of the way there.