What feature flags in a monolith are for, and who needs them
A feature flag is a conditional code path, controlled at runtime, that lets you ship code to production without exposing it to users. The point is to separate deploy (the binary is running in production) from release (users can reach the new behavior). A useful flag has three properties: it is short-lived, it has a named owner, and it is designed to be removed once the decision it controls has been made.
In a service fleet, you can stage a risky change by deploying one service to a canary pool while everything else stays put. A monolith gives you no such seam. Every deploy replaces the whole application, so the deployable itself cannot be the unit of gradual rollout. The flag takes that role instead. It lets you expose new behavior to 1% of requests, one tenant, or internal staff while the code sits in the same process as everything else. It also lets you merge unfinished work behind an off switch, so the main branch stays deployable at all times.
Who benefits most
- Teams practicing trunk-based development on a shared monolith. Many engineers merge into one branch daily, and flags keep half-built features from blocking each other's releases.
- Multi-tenant B2B backends. You can enable a change for one customer, confirm it works, then widen the rollout.
When not to use a flag
- Trivial changes. If a revert is cheaper than a flag, skip the flag.
- Permanent configuration. Settings that will never be removed belong in a configuration system, not a flag.
- Incompatible schema changes. If the old and new code paths cannot share one database schema, a flag cannot keep both paths running at once.
Flag types, lifetimes and owners
Flags that are meant to be removed still differ in how long they live and who answers for them. Classify each flag when it is created. Every later decision depends on that label: who reviews it, when it expires, and what "done" means.
| Type | Expected lifespan | Owner | Review rule | Removal trigger |
|---|---|---|---|---|
| Release | Days to weeks | Team shipping the feature | Reviewed at each rollout step | Reaches 100% and stays stable for one release cycle |
| Experiment | One experiment, typically weeks | Product or analytics owner, with an engineering co-owner | Reviewed when the experiment concludes | A decision is recorded and the losing branch is deleted |
| Operational kill switch | Long-lived | Service or on-call owner | Periodic audit that it still works and is still needed | The dependency it guards is removed or replaced |
| Permission / entitlement | Indefinite | Product or billing owner | Treated as product configuration, not code | Not removed. Moved out of the flag system |
How types get mixed
Flag debt usually starts with a release flag that never finishes. Suppose a rollout stalls at 30% because a large customer asks to keep the old behaviour. Someone pins that customer to "off" and moves on. The release flag now acts as an entitlement, but it still sits in the release-flag list with an engineering owner who no longer remembers it. Its removal trigger can never fire, so it stays forever.
When a flag's purpose changes, reclassify it explicitly. If it has become an entitlement, move it into your permissions or plan model. Then delete the flag and every branch that reads it. A flag whose type nobody can name has become permanent configuration that nobody manages.
How deterministic percentage rollout works
A percentage rollout needs a stable assignment. Take a stable identifier, concatenate it with the flag key, hash the result, and reduce it modulo 100 to get a bucket from 0 to 99. The flag is on when the bucket is below the rollout percentage. Raising the percentage from 10 to 25 keeps the original 10% enabled and adds more, because no existing bucket moves.
Evaluating randomly on each request breaks this. A user would see the new checkout flow on one page load and the old one on the next. Your metrics would mix both experiences in the same session.
Hashing the identifier alone, without the flag key, causes a different problem. Every flag then shares one bucket per user. The same 5% of users receive every early rollout. They absorb all the risk, and their results for one flag are confounded by every other flag.
| Rollout unit | Consistency | Typical use | Risk |
|---|---|---|---|
| Per-user | Sticky per person | Consumer UI changes | Users in one tenant see different behaviour |
| Per-tenant | Sticky per account | B2B, data-model changes | Coarse steps; one large tenant dominates a percentage |
| Per-request | None | Load shedding, infra tuning | Inconsistent experience |
Multi-tenant systems usually bucket by tenant. Colleagues share data and screens, and a feature visible to one of them but not another generates support tickets.
The weak spots are requests with no tenant. Logged-out traffic, background jobs and cross-tenant workflows force a fallback to a session or request ID. Behaviour can then change at login or between job retries. Decide how each of these cases falls back before the rollout starts, rather than after the first inconsistency report.
import hashlib
from typing import Optional
def bucket(flag_key: str, unit_id: str) -> int:
# Hash the flag key with the unit ID so each flag gets its own bucket.
# Hashing unit_id alone would put the same 5% first in every rollout.
digest = hashlib.sha256(f"{flag_key}:{unit_id}".encode("utf-8")).digest()
# Python's built-in hash() is salted per process, so it is not stable.
# SHA-256 gives the same bucket on every host and every restart.
return int.from_bytes(digest[:8], "big") % 100
def rollout_unit(ctx: dict) -> Optional[str]:
# Prefer tenant-level consistency. The fallbacks are explicit and ordered.
# A session or request fallback can flip at login or between job retries.
for key in ("tenant_id", "session_id", "request_id"):
if ctx.get(key):
# Prefix with the key type so tenant "42" and session "42" differ.
return f"{key}:{ctx[key]}"
return None
def is_enabled(flag_key: str, percentage: int, ctx: dict) -> bool:
unit = rollout_unit(ctx)
if unit is None:
return False # No identifier at all: fail closed.
# Buckets never move, so raising 10 -> 25 keeps the first 10% enabled.
return bucket(flag_key, unit) < percentage
# Same tenant, same answer on every request and every server.
print(is_enabled("new-checkout", 10, {"tenant_id": "acme-corp"}))
# Different flag, independent bucket for the same tenant.
print(is_enabled("invoice-export-v2", 10, {"tenant_id": "acme-corp"}))
# Logged-out traffic falls back to the session ID.
print(is_enabled("new-checkout", 10, {"session_id": "sess-7f3a9c"}))
A deterministic rollout check that hashes the flag key with a tenant ID into a stable 0–99 bucket and falls back to session or request IDs in an explicit order when no tenant is present.
Implementing flag evaluation, storage and propagation
Evaluate each flag once, at the request boundary or in a small service object, and pass the resulting boolean or variant down. Deep domain code and SQL builders should receive "use new pricing: yes" as a plain argument, not call the flag client. A single evaluation per request also prevents one request from seeing two answers if the cache refreshes mid-request.
Background jobs need the same treatment. Record the decision in the job payload at enqueue time rather than re-evaluating in the worker. Otherwise a retry an hour later can run different code than the request that created it, which is the retry drift described above.
request ──> boundary ──> evaluator ──> in-process cache <── refresh ── central store
│ (polling or push)
└── decision ──> domain code
└── decision ──> job payload ──> queue ──> worker (reads, never re-evaluates)
| Storage | Change speed | Outage behaviour | Per-check cost |
|---|---|---|---|
| Config file | Requires a deploy | None at runtime | Memory read |
| Database table | Immediate | Fails with the database | Query, unless cached |
| Cache refreshed from central store | Seconds to minutes | Serves last known values | Memory read |
Never query the database on every check. Load all flags into memory and refresh them on an interval. When the store is unreachable, keep serving the last known values. If nothing has ever loaded, fall back to a default defined in code.
That default needs care. If it is "raise", a store outage takes down the app. If it is "on", the same outage silently ships an unfinished feature to 100% of users. For release flags, the code default should almost always be off.
import logging
import threading
import time
# Defaults used only if the store has never loaded. Release flags default to off.
CODE_DEFAULTS = {"new_pricing": False}
class FlagCache:
def __init__(self, fetch_all, interval_seconds=30):
self._fetch_all = fetch_all # one call to the central store returns every flag
self._values = None # stays None until the first successful load
self._interval = interval_seconds
threading.Thread(target=self._refresh_loop, daemon=True).start()
def _refresh_loop(self):
while True:
try:
self._values = dict(self._fetch_all()) # swap the whole dict in one assignment
except Exception:
# Store unreachable: keep serving the last known values. Never raise into requests.
logging.warning("flag refresh failed; serving last known values", exc_info=True)
time.sleep(self._interval)
def is_on(self, name):
values = self._values # memory read only, no query per check
if values is None or name not in values:
return CODE_DEFAULTS.get(name, False) # unknown flags are also off
return bool(values[name])
def handle_checkout(request, flags, queue):
# Evaluate once at the request boundary, then pass a plain boolean down.
use_new_pricing = flags.is_on("new_pricing")
total = price_cart(request.cart, use_new_pricing=use_new_pricing)
# Record the decision in the payload so a retry an hour later runs the same code path.
queue.enqueue("send_receipt", {"order_id": request.order_id,
"use_new_pricing": use_new_pricing})
return total
def send_receipt(payload):
# The worker reads the recorded decision and never calls the flag cache.
render_receipt(payload["order_id"], use_new_pricing=payload["use_new_pricing"])
This sketch shows an in-process flag cache that refreshes from a central store, keeps the last known values when the store is down and falls back to off-by-default code values. The flag is evaluated once per request, and the decision is stored in the job payload so the worker never re-evaluates it.
Schema changes and running a gradual rollout safely
One schema, two code paths
In a monolith, the old and new paths run against the same database at the same time, so every schema change has to work for both. Use expand-and-contract. First add the new columns or tables without removing anything the old path depends on. Then let the flagged path dual-write: write the new shape and keep the old one populated. Alternatively, have it dual-read, preferring the new shape and falling back to the old. Drop the old structures only after the flag is retired.
This has a hard limit. Once the new path writes data the old path does not understand, turning the flag off does not undo those writes. Before you raise the percentage, confirm that the old path can read every record the new path creates, or that a backfill script exists to repair those records.
Stepped rollout
- Enable the flag for internal users only, and confirm the off path still works.
- Move to a small percentage. Compare error rate, latency, and business metrics for the flagged cohort against the unflagged cohort.
- Increase in larger steps, holding at each step long enough to see normal traffic patterns.
Write the rollback criteria down before you start, for example "p95 latency for the cohort rises more than an agreed margin". Writing them first means nobody argues about thresholds during an incident. The kill switch must flip at runtime, without a deploy.
Percentages do not bound every kind of failure. A slow query, lock contention, or memory growth in a shared process degrades service for all users, even when only a small share of traffic runs the new path. Kill switches whose off path has never been exercised can also fail exactly when you need them, so test turning the flag off as deliberately as you test turning it on.
# Expand step (already applied): ALTER TABLE customers ADD COLUMN given_name TEXT;
# ALTER TABLE customers ADD COLUMN family_name TEXT;
# The old column full_name stays until the flag is retired (contract step).
def flag_enabled(db, name, user_id):
# Read flag state from the database on every call so a kill switch
# takes effect at runtime, with no deploy or restart.
row = db.execute("SELECT enabled, percent FROM feature_flags WHERE name = ?",
(name,)).fetchone()
if row is None or not row[0]:
return False # missing or killed flag means the old path
return user_id % 100 < row[1] # stable cohort: same user, same answer
def save_name(db, user_id, given, family):
full = f"{given} {family}".strip()
if flag_enabled(db, "split_names", user_id):
# Dual-write: populate the new columns AND keep full_name current,
# so the old path can still read this row if the flag is turned off.
db.execute("UPDATE customers SET given_name = ?, family_name = ?, full_name = ? "
"WHERE id = ?", (given, family, full, user_id))
else:
# Old path writes only the old shape; new columns go stale, so the
# new read path must not trust them blindly.
db.execute("UPDATE customers SET full_name = ?, given_name = NULL, "
"family_name = NULL WHERE id = ?", (full, user_id))
def display_name(db, user_id):
given, family, full = db.execute(
"SELECT given_name, family_name, full_name FROM customers WHERE id = ?",
(user_id,)).fetchone()
if flag_enabled(db, "split_names", user_id) and given is not None:
return f"{given} {family or ''}".strip() # dual-read: prefer new shape
return full # fall back to the old shape, which every path keeps valid
A flagged name-splitting change that dual-writes the new and old columns, dual-reads with a fallback, and re-reads flag state on each call so turning the flag off is a runtime data change that leaves every row readable by the old path.
Testing with flags and enforcing flag cleanup
Every active flag means the monolith ships two programs in one binary, and both need tests. For each flag, cover the on and off paths at the level where the behavior differs, usually unit or integration tests that set the flag explicitly instead of reading shared configuration. For flags that guard payments, authentication or data writes, add a CI matrix that runs the relevant suite once with the flag on and once with it off. That way a change that breaks only the dark path still fails the build.
You can't test every combination of flags, so limit how many can interact. If three flags touch the same request path, that's eight states. Most of them have never run together, and when production misbehaves you can't reproduce which mix a given user saw. A practical rule is that a code path may depend on at most one or two unreleased flags. A new flag in an already-flagged area waits until the earlier one is resolved.
Cleanup is a process, not a cleanup sprint
- Owner and expiry at creation. A flag without both is rejected in review. Release flags get a short expiry. Operational kill switches get a review date.
- Automated staleness report. A lint or CI check lists flags past expiry, or flags fully rolled out for longer than an agreed period, and names their owners.
- Dedicated removal change. Delete the flag check, the dead branch and the tests for the losing state in one reviewable change. Keep it separate from feature work so it is easy to revert.
- Never reuse keys. An old key may still exist in stored overrides or older configuration, and reusing it can silently re-enable behavior nobody intended.
- Track active flag count. Watch the trend. A steadily rising count means flags are being added faster than they are retired.
#!/usr/bin/env python3
"""CI check: every flag needs an owner and expiry. Reports stale flags and the active count."""
import json
import sys
from datetime import date
# flags.json shape:
# {"active": [{"key": "checkout_new_tax", "owner": "payments-team", "expires": "2026-12-15"}],
# "retired": ["checkout_v2", "legacy_login"]}
path = sys.argv[1] if len(sys.argv) > 1 else "flags.json"
with open(path) as f:
registry = json.load(f)
today = date.today()
retired = set(registry.get("retired", []))
errors, stale = [], []
for flag in registry["active"]:
key = flag.get("key", "<missing key>")
# A flag without both an owner and an expiry or review date is rejected.
if not flag.get("owner") or not flag.get("expires"):
errors.append(f"{key}: missing owner or expires")
continue
# Old keys may still exist in stored overrides, so reusing one is an error.
if key in retired:
errors.append(f"{key}: reuses a retired key")
if date.fromisoformat(flag["expires"]) < today:
stale.append(f"{key} (owner: {flag['owner']}, expired {flag['expires']})")
# Print the count on every run so CI logs show the trend over time.
print(f"active flags: {len(registry['active'])}")
for line in stale:
print(f"STALE: {line}")
for line in errors:
print(f"ERROR: {line}", file=sys.stderr)
# Malformed or reused entries fail the build. Stale flags are reported to their owners.
sys.exit(1 if errors else 0)
A standard-library Python CI check reads a flag registry. It fails the build when a flag has no owner or expiry, or when it reuses a retired key, and it reports expired flags by owner along with the active flag count.
Alternatives and impact on cost and engineering time
Flags are one way to separate deploy from release. The alternatives place the cost in different parts of the process.
| Approach | Release unit | Rollback path | Main cost |
|---|---|---|---|
| Long-lived feature branch | Whole feature at merge | Revert and redeploy | Merge conflicts, late integration |
| Branch by abstraction | Implementation behind an interface | Redeploy, unless paired with a flag | Upfront interface design |
| Blue-green or canary of the monolith | Entire build | Traffic switch | Unrelated changes ship and roll back together |
| Extracting a service | One component | Independent deploy | Network failure modes, split data ownership, more operations work |
| Feature flags | Feature, per cohort | Config change | Duplicated code paths, flag platform upkeep |
Every active flag doubles the code paths in the area it touches. Two overlapping flags produce four combinations to reason about. In-house systems also tend to grow. A config file is enough at first, but teams soon need audit logs, targeting rules, permissions and a UI. Decide early whether you will keep a minimal static setup or fund a platform with a named owner.
Illustrative estimate (assumed numbers, not a benchmark)
Monthly savings = (R × E × H) + (B × Eb × Hb) − (F × C)
- R = 4 coordinated releases per month, E = 6 engineers, H = 1 hour of coordination each: 24 hours
- B = 2 rollbacks per month that would otherwise need a redeploy, Eb = 3 engineers, Hb = 1.5 hours: 9 hours
- F = 10 active flags, C = 0.5 hours of upkeep and cleanup per flag per month: 5 hours
Net: 24 + 9 − 5 = 28 engineer-hours per month. This estimate leaves out the time saved on merge conflicts and the reduced risk of smaller deploys. Replace the inputs with your own figures. If C grows faster than R and B, flag debt is costing you more than flags save.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to feature flags in a monolith, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.