What a database-backed feature flag system is and who it suits
A database-backed feature flag system has three parts that live inside your monolith. The first is a feature toggle table that stores each flag's state and rollout rules. The second is an in-process cache that holds those rows in memory. The third is a single is_enabled(flag, context) function that every caller uses to ask whether a feature is on for a given user, account or request. There is no separate service and no SDK to vendor, and evaluation never makes a network hop. The flag data sits next to the rest of your application data and is read through code you already deploy.
Teams build this to separate deploying code from releasing it. You can merge and ship an unfinished feature with its flag off, then turn it on for internal users, then for 5% of accounts, then for everyone, all without a redeploy. The same mechanism works as a kill switch. When a new code path misbehaves in production, you flip one row and the old behavior comes back within one cache refresh interval. In a monolith, every change ships with everything else, so this is often the cheapest way to limit the blast radius of a single change.
This approach fits a specific shape of team. You have one deployable, one primary database and one language. You are also willing to treat flags as short-lived code: each flag has a named owner and an expiry date, and you delete it once the rollout finishes. Within those limits, this design covers most rollout needs. It stops working well when flags turn into permanent configuration. That is the main argument of this tutorial.
Designing the feature toggle table
Keep one row per flag, keyed by a short stable string such as checkout.new_tax_engine. Next to the key, store enabled as a master switch and rollout_percentage as an integer from 0 to 100. Add bucket_by, which names the attribute used for hashing: user ID, tenant ID or session. In a B2B monolith, bucketing by tenant keeps every user in an account on the same code path, which avoids support tickets that read "my colleague sees a different screen."
The owner, description and expected_removal_date columns are what let you clean flags up later. Make them NOT NULL, so nobody can create a flag that has no owner. created_at and updated_at record when things happened. A version integer, incremented on every write, supports optimistic locking and lets caches detect stale entries cheaply.
Store targeting in child tables such as flag_target_users, flag_target_tenants and flag_target_roles, each with (flag_key, target_id) as its primary key. Add a flag_history table that records the old value, the new value, the actor, the timestamp and the version. Write to it in the same transaction as each change.
Child tables vs. a JSON column
| Concern | Child tables | Single JSON column |
|---|---|---|
| Validation | Foreign keys, types, uniqueness | Application code only |
| Indexing | Plain B-tree on target ID | Expression or GIN indexes, engine-specific |
| "Which flags target tenant 42?" | One indexed join | Scan or a specialised index |
| Drift risk | Low: new rule types need a migration | High: nested operators accumulate into a rules engine |
The migration cost of child tables is useful friction, because it makes the team decide on purpose before adding a new kind of targeting rule.
-- PostgreSQL. One row per flag; rules that drive cleanup are NOT NULL.
CREATE TABLE feature_flags (
flag_key text PRIMARY KEY, -- e.g. 'checkout.new_tax_engine'
enabled boolean NOT NULL DEFAULT false, -- master switch
rollout_percentage smallint NOT NULL DEFAULT 0
CHECK (rollout_percentage BETWEEN 0 AND 100),
bucket_by text NOT NULL DEFAULT 'tenant_id'
CHECK (bucket_by IN ('user_id', 'tenant_id', 'session_id')),
owner text NOT NULL, -- who is accountable for removal
description text NOT NULL,
expected_removal_date date NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
version integer NOT NULL DEFAULT 1 -- bumped on every write
);
-- Tenant targeting; flag_target_users and flag_target_roles share this shape.
CREATE TABLE flag_target_tenants (
flag_key text NOT NULL REFERENCES feature_flags (flag_key) ON DELETE CASCADE,
target_id bigint NOT NULL,
PRIMARY KEY (flag_key, target_id)
);
-- Answers "which flags target tenant 42?" with an index lookup, not a scan.
CREATE INDEX flag_target_tenants_target_idx ON flag_target_tenants (target_id);
CREATE TABLE flag_history (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
flag_key text NOT NULL, -- no FK: history must outlive deleted flags
version integer NOT NULL,
old_value jsonb, -- row snapshot; NULL when the flag is created
new_value jsonb, -- row snapshot; NULL when the flag is deleted
actor text NOT NULL,
changed_at timestamptz NOT NULL DEFAULT now()
);
-- Optimistic write: 0 rows updated means someone else changed the flag first.
UPDATE feature_flags SET rollout_percentage = 25, version = version + 1, updated_at = now()
WHERE flag_key = 'checkout.new_tax_engine' AND version = 7;
A PostgreSQL schema for the flag table, one targeting child table and the history table, followed by a version-checked update that is meant to run in the same transaction as its flag_history insert.
How evaluation works: deterministic rollouts and a clean API
Business code should ask one question and nothing else: is_enabled(flag_key, context). The context carries identifiers such as user ID, tenant ID and request ID. The caller never reads rows, compares percentages or catches evaluation errors. Each flag also stores an explicit default value. The evaluator returns that default when the key is missing from the cache, the row is malformed or anything throws. A typo in a flag name then degrades to a known state instead of an exception on the request path.
Evaluation runs in a fixed order:
- Kill switch. If set, return off.
- Explicit targeting. Allow and deny lists match on the chosen identifier.
- Percentage rollout.
- Default.
Percentage bucketing must be deterministic. Hash the string flag_key + ":" + bucketing_id with a stable function such as SHA-256 or MurmurHash, take the first 32 bits as an unsigned integer, and reduce it modulo 10,000 to get a bucket between 0 and 9,999. The flag is on when bucket < percentage × 100. A user's bucket never changes, so raising the rollout from 10% to 30% only adds users and never reshuffles the ones already enabled. Salting the hash with the flag key stops the same 10% of users from receiving every experiment at once. Avoid language-native hash functions that are randomized per process.
Pick the bucketing key per flag and store it on the row:
- User ID fails for anonymous traffic, which has no user ID. It also fails for background jobs, which run outside any user.
- Tenant ID should drive the decision in multi-tenant systems. Otherwise half of one customer's staff sees a different product from the other half.
- Request ID or job ID suits infrastructure changes where stickiness doesn't matter.
import hashlib
def bucket(flag_key: str, bucketing_id: str) -> int:
# Salt with the flag key so each flag picks a different set of users.
digest = hashlib.sha256(f"{flag_key}:{bucketing_id}".encode()).digest()
# First 32 bits as unsigned int, reduced to 0..9999 (0.01% resolution).
return int.from_bytes(digest[:4], "big") % 10_000
def is_enabled(flag_key: str, context: dict, cache: dict) -> bool:
flag = cache.get(flag_key)
if flag is None:
return False # unknown key: no row, so no per-flag default exists
try:
if flag["kill_switch"]:
return False # 1. kill switch always wins
# Per-flag bucketing key: "user_id", "tenant_id", "request_id" or "job_id".
bid = context.get(flag["bucketing_key"])
if bid is None:
return flag["default"] # e.g. anonymous traffic on a user-keyed flag
bid = str(bid)
if bid in flag.get("deny", ()):
return False # 2. explicit targeting
if bid in flag.get("allow", ()):
return True
pct = flag.get("percentage")
if pct is not None: # 3. rollout; raising pct only adds buckets
return bucket(flag_key, bid) < int(pct * 100)
return flag["default"] # 4. default
except Exception:
# Malformed row: degrade to the flag's known state, never raise.
return bool(flag.get("default", False))
cache = {"new-invoice-ui": {"kill_switch": False, "bucketing_key": "tenant_id",
"allow": {"tenant-42"}, "deny": set(), "percentage": 25, "default": False}}
print(is_enabled("new-invoice-ui", {"tenant_id": "tenant-7"}, cache))
A SHA-256 hash-to-bucket function and an is_enabled evaluator that applies kill switch, targeting, percentage and default in order, using a per-flag bucketing key and never raising.
Flag caching without a query on the request path
Evaluating flags against the database on every request would add a query to every code path that checks one. Load the whole table into memory instead. It is usually small, and a single SELECT per refresh is cheap.
The data flows in one direction: database → refresher (one per process) → in-process cache → request handlers. Handlers only ever read memory.
The refresher wakes on a short interval, typically 5–30 seconds. It runs a cheap staleness check first, such as max(updated_at) or a single-row version counter that the write path increments. It reloads the full flag set only when that value changes, then swaps the cache reference atomically. Readers never see a half-built map. Where the database offers notifications, such as PostgreSQL's LISTEN/NOTIFY, treat them as a hint to refresh early. Keep the timer as the backstop, because notifications can be missed during reconnects.
At the start of each request or job, take a snapshot by grabbing the current map reference and passing it through. A flag that flips mid-request then cannot send one handler down both branches.
Trade-offs
- Propagation delay. A change takes up to one interval to reach every process.
- Temporary disagreement. During that window, different processes can return different answers for the same flag.
- Load from polling. Illustrative estimate: assume 200 processes polling every 5 seconds. That is 40 version checks per second. Polling every 30 seconds drops it to about 7.
- Database failures. If the database is unreachable at startup, fall back to defaults compiled into the code. If a refresh fails, keep serving the stale cache and alert. The catch is that a stale cache also delays kill switches. For that reason, a kill switch's default should be the safe "off" state.
import logging, threading, time
from types import MappingProxyType
log = logging.getLogger("flags")
# Compiled-in fallbacks used until the first successful load. Kill switches default to off.
DEFAULTS = {"new_checkout": False, "kill_report_export": False}
class FlagCache:
def __init__(self, connect, interval_s=10):
self._connect = connect # returns a DB-API connection
self._interval_s = interval_s
self._flags = MappingProxyType(dict(DEFAULTS))
self._version = None
def _refresh_once(self):
conn = self._connect()
try:
cur = conn.cursor()
# Cheap staleness check: one row, bumped by every flag write.
cur.execute("SELECT version FROM flag_version")
(version,) = cur.fetchone()
if version == self._version:
return
cur.execute("SELECT name, enabled FROM feature_flags")
new = dict(DEFAULTS)
new.update({name: bool(enabled) for name, enabled in cur.fetchall()})
# Build fully, then swap one reference: readers see old or new, never half.
self._flags = MappingProxyType(new)
self._version = version
finally:
conn.close()
def _loop(self):
while True:
try:
self._refresh_once()
except Exception:
log.exception("flag refresh failed; serving stale cache") # alert on this
time.sleep(self._interval_s)
def start(self):
threading.Thread(target=self._loop, daemon=True).start()
def snapshot(self):
# Call once per request or job and pass the result down the call chain.
return self._flags
A per-process flag cache that polls a version row, reloads and atomically swaps the full flag map only when the version changes, keeps the stale map on refresh failure, and hands each request a read-only snapshot.
Implementing the write path, kill switch and rollout workflow
Every flag change goes through one write path: an admin endpoint or a reviewed, migration-style change. Each write records who changed the flag, when, the old and new values, and a required reason. The update and its audit row commit in a single transaction, so the history always matches the state. The example below shows this pattern: lock the flag row, write the new value, insert the audit entry, then commit.
Direct SQL edits against the feature toggle table are a common cause of incidents. They skip validation, so a typo can set a rollout to 100 instead of 10. They also leave no record of why the change was made. When something breaks at 2 a.m., the on-call engineer cannot tell whether a flag changed. Revoke write access to the table from human accounts and keep only the service account.
Kill switch semantics
A kill switch is an ordinary write. Every process sees it only after its next cache refresh. Tell operators that "off" takes effect within one refresh interval, not instantly. Size that interval to match the longest exposure you can tolerate for a bad feature.
Rollout workflow
- Ship dark. Deploy the code with the flag off and confirm that the deploy itself is clean.
- Enable for internal users. Target staff accounts by ID before any customers see the feature.
- Ramp the percentage. Move in steps such as 1, 5, 25, 50, 100. Bucketing is deterministic, so each step keeps the users already enabled and adds new ones.
- Watch at each step. Compare error rates and latency for the flagged path against the baseline. Hold each step for at least a few refresh intervals before moving to the next.
- Remove the flag. In a follow-up change, delete the flag, its dead branch and its row.
-- Audited flag update (PostgreSQL). The admin endpoint runs this as the
-- service account; humans have no direct write access to feature_flags.
-- Assumes CHECK (rollout_percent BETWEEN 0 AND 100) on feature_flags and
-- NOT NULL + CHECK (length(reason) > 0) on flag_audit.reason.
BEGIN;
WITH old AS (
-- Lock the flag row so concurrent edits serialize and the audit row
-- records the true previous value.
SELECT name, enabled, rollout_percent
FROM feature_flags
WHERE name = 'new_checkout'
FOR UPDATE
),
upd AS (
UPDATE feature_flags f
SET rollout_percent = 10,
updated_at = now()
FROM old
WHERE f.name = old.name
RETURNING f.name, f.enabled, f.rollout_percent
)
-- Who, when, old and new values, and why, written in the same transaction.
INSERT INTO flag_audit
(flag_name, changed_by, changed_at,
old_enabled, old_percent, new_enabled, new_percent, reason)
SELECT old.name, 'alice@example.com', now(),
old.enabled, old.rollout_percent, upd.enabled, upd.rollout_percent,
'Ramp step 2: 1% clean for 3 refresh intervals'
FROM old
JOIN upd ON upd.name = old.name;
-- If the flag does not exist, nothing is updated and no audit row is
-- written. The endpoint should check the row count and roll back.
-- Otherwise the state change and its audit row become visible together.
COMMIT;
A single PostgreSQL transaction that locks the flag row, updates the rollout percentage, and records who changed it, when, the old and new values and the reason, so the audit history always matches the flag state.
Testing flags and keeping flag debt under control
Tests should never depend on what happens to be in the toggle table. Pass the evaluator into the code that checks flags, through a constructor or a request context, instead of reading it from a global. A unit test can then supply an in-memory evaluator with fixed states. For integration tests, add an override layer that sits in front of the cached evaluator, so a test can pin checkout.new_tax_engine to on without writing a row that another test might read.
Do not try to test every combination. With n flags there are 2ⁿ states, so this grows out of reach quickly. Mark the flags that guard risky paths, such as payments, data migrations or auth, and run the relevant suites twice in CI: once with each of those flags forced on and once with them forced off. Leave all other flags at their defaults.
Keeping flag debt visible
Make owner and expires_at required columns, and reject any write that leaves them out. Then add a scheduled job or CI check that:
- Lists flags past their expiry date and notifies the owner.
- Searches the codebase for flag keys and compares them with the table. A key that exists only in the table is a dead row. A key that exists only in code is a check that always falls back to its default.
- Reports flags that have been at 0% or 100% for weeks.
Long-lived flags cause three problems. Each one doubles the number of code paths through the code it touches. Each one adds a state that tests have to account for. And after a while, no one remembers what the flag is for, so it stays in place as permanent configuration with no owner.
When to avoid it, alternatives, and the impact on cost and engineering time
When to avoid it
Skip this design if several services or languages need the same flag result. Each one has to reimplement the hashing and caching exactly, and any drift means a user sees a feature in one service but not the next. Also skip it if product needs rich segment targeting or statistically sound experiments. Finally, skip it if no one will own the admin UI, audit trail and permissions. A toggle table that anyone can UPDATE lets people change production with no review.
| Option | Propagation | Targeting | Audit | Multi-service | Ownership cost |
|---|---|---|---|---|---|
| Config files / env vars | Redeploy or restart | None | Version control history | Per service | Very low |
| Database-backed | Cache TTL | Percentage, allowlists | Only what you build | Weak across languages | Low at first, grows |
| Open-source self-hosted server | Near real-time | Rich | Built in | SDKs per language | Hosting, upgrades, on-call |
| Managed service | Near real-time | Rich, experiments | Built in | SDKs per language | Subscription fees |
Build vs buy
The table and evaluator take days to build. The cost comes later, as requests pile up for role-based permissions, approval steps, segment targeting and a better UI.
Flags save engineering time in three ways. A rollback becomes a flag flip instead of a revert and redeploy. Deploys are decoupled from releases. Long-lived branches give way to code merged behind a flag. Flags cost time in cache bugs, UI upkeep and cleanup.
Illustrative estimate (assumed numbers, not a benchmark): Net hours per month = rollbacks × (redeploy min − flip min) / 60 + merge hours avoided − maintenance hours − flags retired × cleanup hours.
Assumed inputs: 4 rollbacks a month, 40 minutes per redeploy rollback versus 2 minutes per flip, 5 merge hours avoided, 2 maintenance hours, and 4 flags retired at 0.5 hours each.
4 × 38 / 60 + 5 − 2 − 2 ≈ 3.5 hours.
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 self-hosted feature flags database, 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.