The short answer: an environment variable is a deploy-time decision and a feature flag is a runtime one. ENABLE_NEW_CHECKOUT=true is read once when the process boots, so changing it means changing config and restarting or redeploying every instance. A feature flag is evaluated on every request against a value the platform can change in seconds, for a percentage of users, without touching the deploy pipeline. Env vars are the right tool for configuration that differs per environment and rarely changes. Flags are the right tool for behavior you need to change while the code is already running.
That distinction sounds academic right up until the first time you need to turn something off at 11pm and discover that the only lever you have requires a full deploy.
What an environment variable is actually good at
This is not an article about how env vars are bad. They are excellent at the job they were designed for, and a lot of teams reach for a flag platform far too early.
Environment variables are the right answer for anything that is genuinely a property of where the code is running: the database host, the API base URL, the S3 bucket, the log level, the Stripe key. These values differ between staging and production, they are set once at provision time, and they should absolutely not be editable from a web UI by whoever has a login. The twelve-factor guidance to keep config in the environment holds up fine.
They are also the right answer for a boolean you will flip exactly once. If you are gating a migration that runs on a Saturday and then the flag is deleted on Monday, an env var and a redeploy is less machinery than onboarding a platform. Do not buy anything for that.
Where env vars stop being enough
The trouble starts when teams keep using a deploy-time mechanism for a growing pile of runtime decisions. It happens gradually, one boolean at a time, and there are five fairly predictable walls.
Wall 1: changing one takes a deploy
This is the big one. To flip ENABLE_NEW_CHECKOUT you edit config, then restart or redeploy every instance that reads it. On a well-run pipeline that is maybe 5 to 15 minutes. During an incident, 15 minutes of a broken checkout is real money, and you are shipping a config change through the same pipeline that is possibly the thing that broke. Compare that to a feature flag kill switch, where the change propagates to running processes in seconds and no build runs at all.
Wall 2: it is all-or-nothing
An env var is one value for the whole process. There is no honest way to express "on for 5% of users" or "on for accounts on the enterprise plan" in a single boolean read at boot. Teams work around this by hand-rolling a hash of the user ID against a percentage they also stuff into an env var, which works until they need the same user bucketed consistently across three services and discover each service hashes slightly differently. Consistent bucketing is deceptively hard, which is most of the argument in build vs buy feature flags.
Wall 3: only engineers can change it
If flipping a behavior requires shell access or a pipeline run, then every request to enable a beta for a customer routes through an engineer. That is fine at five flags. At fifty, someone on your team is a full-time toggle operator, and product managers stop asking for controllable rollouts because the cost of asking is too high.
Wall 4: no history
When something breaks and you ask "when did this change and who changed it", an env var gives you whatever your deploy tooling happened to log. Often that is a commit to a config repo, which is decent. Often it is a value someone edited in a hosting dashboard, which is nothing at all. Flag platforms record who flipped what, when, and from what value, because that audit trail is the first thing anyone wants during a post-mortem.
Wall 5: config drift between environments
Once you have thirty booleans across four environments, they diverge. Staging has a flag production does not. Someone adds a variable to the deploy template and forgets one region. Nothing surfaces the difference until behavior differs and nobody can say why. A single source of truth with per-environment values is a boring fix, but it is the fix.
The comparison, side by side
| Dimension | Environment variable | Feature flag |
|---|---|---|
| When it is read | Process start | Every evaluation, at runtime |
| Time to change | A deploy or restart, typically 5 to 15 minutes | Seconds, no deploy |
| Granularity | One value per process | Per user, cohort, percentage or attribute |
| Who can change it | Engineers with pipeline or shell access | Anyone you grant permission to |
| History | Whatever your deploy tool logged | Audit trail per change |
| Gradual rollout | Not without hand-rolled hashing | Built in, consistent bucketing |
| Measuring impact | Nothing built in | Exposure events and metrics |
| Right for | Secrets, hosts, per-environment config | Release control, rollouts, entitlements |
| Cost | Free, already there | A platform, or one you build |
Read the last two rows together. If your booleans are genuinely environmental, the free option wins and you should stop reading. The case for a platform is entirely about the middle rows.
Questions people actually ask
Can you use environment variables as feature flags?
Yes, and for a handful of long-lived on/off switches it works fine. The limits are structural rather than a matter of discipline: a variable read at boot cannot be changed without a restart, cannot hold a different value for different users, and carries no record of who changed it. Once you need any of those three things, you are building a flag system on top of config rather than using config.
What is the difference between a feature flag and a config file?
Both store values outside your code. The difference is the update path and the granularity. A config file ships with a deploy and applies to every request the process serves. A feature flag lives in a service that running processes subscribe to, so its value can change mid-flight and can differ per user based on targeting rules. A config file is a constant; a flag is a variable.
Should feature flags be stored in a database?
A database table is a reasonable first step and better than env vars, because you get runtime updates and history for free. The parts you still have to build are the ones that get expensive: consistent percentage bucketing across services, a caching layer so you are not querying per request, per-environment values, an interface non-engineers can use, and safe defaults when the database is unreachable. That is a service, not a table.
How many feature flags is too many?
There is no fixed number, but the useful signal is the ratio of flags to owners. A flag with a named owner and a removal date is an asset. A flag nobody remembers creating is dead code with a conditional wrapped around it. Most teams are fine into the low hundreds if they clean up release flags after a rollout completes; the feature flag best practices that keep this manageable are naming, ownership and an expiry date on every temporary flag.
Do feature flags slow down your application?
A well-implemented flag read is a local memory lookup against a ruleset the SDK already holds, so it costs microseconds and no network call. The slow implementations are the ones that make an HTTP request per flag evaluation, which is why local or bootstrapped evaluation matters. If a vendor's SDK calls out on every read, that is the thing to check before you adopt it.
How to migrate without a rewrite
You do not need a big-bang cutover, and you should not attempt one. The mechanism that makes this safe is that both systems can coexist for as long as you want.
- Inventory what you have. Grep for every boolean-ish env var across your services and sort them into two piles: genuinely environmental (keep as config) and behavioral (candidates for flags). Most teams find the split is not close, and that the behavioral pile is where all the incidents live.
- Wrap the read, do not replace it. Introduce one function,
isEnabled('checkout.new-flow'), and have it fall back to the existing env var when the flag service has no opinion. Nothing breaks, and you can move call sites one at a time. - Move the noisiest one first. Pick the variable that has caused the most deploys in the last quarter. That is where the payback is obvious and where you will win the argument about whether this was worth doing.
- Keep safe defaults everywhere. Every read takes a fallback value that is the current production behavior. If the flag service is unreachable, the app keeps serving what it served yesterday rather than failing or flapping.
- Delete the env var once it is unused. Two sources of truth for one behavior is worse than either alone. Finish the move, then remove the variable and the fallback branch.
Step four is the one people skip and regret. A flag read that throws when the network is down has made your release tooling a hard dependency of your request path, which is exactly backwards.
The thing to watch during the migration
Moving a behavior from a deploy-time switch to a runtime one changes your failure modes. Previously a bad value could only arrive with a deploy, which meant it arrived during working hours with someone watching. Now it can arrive at any time, from anyone with permission. That is the whole point, and it is also a new class of incident.
The mitigation is not to slow the flag down, it is to watch it. Attach the flag key to your error events so a spike is attributable to a specific flip rather than to a vague deploy window, and make sure whatever watches your endpoints and alerts you when they start failing is running independently of the system you just gave a new remote control to. If the flag service and the alerting are the same vendor, a bad day for them is a very bad day for you.
So which one should you use?
Use environment variables for configuration: things that describe the environment, that change when you provision, and that you would be uncomfortable letting a non-engineer edit. Use feature flags for behavior: things you want to change while the system is running, expose to some users and not others, roll out gradually, or turn off in a hurry.
The mistake is not picking the wrong one at the start. It is never revisiting the choice, so that three years later a company with 200 engineers is redeploying a fleet to change a boolean, because that is how it was done when there were four of them and it worked fine then. If that is where you are, a feature flags platform with real gradual rollout control is the upgrade, and moving one variable at a time is the way to get there.