WritingEngineering
Tenant feature flags: a column, a flags table or a flag service
Decide where a per-tenant feature flag lives by who flips it, and read it once per job so a flip never lands halfway through a tenant's work.
Thirty tenants share one deployment of a compliance product. On Monday the product owner wants tenant acme on the new statement-of-work parser, while the other 29 stay on the old one until their reviewers have been trained on the new field layout. Somewhere a record has to say that acme has new_sow_parser on and nobody else does, and the question is where that record lives: in a JSON column on the tenant row, in a table with one row per tenant and flag, or in a flag service with its own dashboard and SDK. I moved a compliance product from single-tenant to multi-tenant SaaS, and this is how I would settle the question now.
The store is decided by who flips the flag and how fast the flip has to land, not by how many flags you have. And whichever store wins, a flag must be read once per unit of work and its value written onto what that work produced, because a flip that lands halfway through a 400-document batch is worse than either setting of the flag.
What decides it
| Criterion | JSON column on the tenant | tenant_flags table | Flag service |
|---|---|---|---|
| Who flips it | An engineer, with a migration or a database console | Ops, through an admin screen that writes a row | Product, through the vendor's dashboard |
| Record of the change | None unless you add a history table yourself | One row per change, with changed_by and changed_at | Kept by the service, exported on request |
| Read cost | Nothing extra: the tenant row is already loaded | One indexed query per request, or a cache in front of it | A network call on cold start, then the SDK's local cache |
| Agreement between instances | Every instance reads the same row on each request | The same, until you add a cache with a TTL | Each SDK polls on its own interval, so instances disagree for seconds |
| Turning it on for many tenants | Edit the JSON on each row | One UPDATE, or a batch insert by tenant segment | A percentage of tenants by tenant id, or a segment rule |
| Meaning for a new tenant | The column default | A missing row, whose meaning you must define | The service's default rule |
| Retiring a flag | A migration that removes the field from every row | Delete the rows and the code in one release | Archive it in the dashboard, then delete the code |
The first row settles most cases on its own. If the people who want a flag flipped cannot do it themselves, every flip becomes a ticket, and a ticket queue is a slower and less visible flag store than any of the three.
The flip that lands halfway through a batch
Follow one job through the column option, since it is the one teams start with. At 09:12:00 a worker starts a re-parse of acme's 400 statement-of-work documents, and for each document it loads the tenant row and reads flags.new_sow_parser. At 09:14:05 ops flip the flag to true for acme. Documents 1 to 181 were parsed by the old parser, which stores each clause as clause_text; documents 182 to 400 go through the new parser, which stores text and obligation_type. The job finishes at 09:19 with status succeeded, and nothing in the logs is marked as an error.
The reviewer sees it the next morning. The vendor audit comparison, which diffs each document against its previous parse, marks 219 of acme's documents as changed, although not one contract was amended. The diff alert fires on acme and a reviewer spends the morning confirming that 219 changes are a field layout, not a change of terms. The same failure happens with the table and with the service, because the fault is a read per document, not the store the read came from.
What contains it is a snapshot. The job reads the flags it depends on once, when it starts, and writes them into a flag_snapshot column on the job row; every parsed document carries a parser_version copied from the job, never from the flag store. The comparison then joins on parser_version and compares like with like, and a layout change shows up as a version change on one report rather than 219 false amendments.
// simplified: evaluate once per unit of work, then stamp the result
type FlagSnapshot = { values: Record<string, boolean>; takenAt: string };
async function startReparseJob(tenantId: string, docIds: string[]) {
const snapshot: FlagSnapshot = await flags.snapshot(tenantId, ['new_sow_parser']);
const job = await jobs.insert({
tenantId,
flagSnapshot: snapshot, // stored on the job row, not re-read later
parserVersion: snapshot.values.new_sow_parser ? 'v2' : 'v1',
});
for (const docId of docIds) {
await queue.publish('parse_document', { jobId: job.id, docId });
}
}
async function parseDocument(msg: { jobId: string; docId: string }) {
const job = await jobs.get(msg.jobId);
// the per document step takes the version from the job, never from the store
const parser = job.parserVersion === 'v2' ? parseV2 : parseV1;
const result = parser(await docs.load(msg.docId));
await parsed.insert({
...result,
docId: msg.docId,
parserVersion: job.parserVersion,
});
}The flags.snapshot call is the one place that knows which store is behind it, so the choice below can change later without touching a job.
The three stores side by side
JSON column
Best for A handful of flags that only engineers flip
A flags JSON column on the tenant row, read with the tenant on every request.
Strengths
- No extra query, no new table, and a flag is visible wherever the tenant row is already loaded
- A new tenant gets the column default, so there is no undefined state to reason about
Costs
- No record of who flipped what or when, unless you build a history table, at which point you have built the second option badly
- Turning a flag on for 40 of 60 tenants is 40 row edits, and a typo in one tenant's JSON is found by the next request, not by a constraint
tenant_flags table
Best for Ops flip flags from an admin screen and auditors ask who did
One row per tenant and flag: tenant_id, flag, enabled, changed_by, changed_at, with the previous rows kept rather than updated.
Strengths
- The change record is the table itself, and a reviewer's question about when acme moved to the new parser is one query
- A segment rollout is one INSERT with a WHERE over the tenants table
Costs
- A missing row has to mean something, and the day a new tenant is created without rows is the day you find out whether you defined it
- One query per request, so a cache appears, and the cache TTL becomes the time two instances can disagree about one tenant
Flag service
Best for Product needs percentage rollouts or a kill switch across every instance in seconds
A hosted or self-hosted flag service with an SDK, where the tenant id is a targeting attribute on every evaluation.
Strengths
- A flag can be turned off for every tenant from one screen without a deploy or a database session
- Percentage rollouts hash the tenant id, so acme is either in or out and stays that way between evaluations
Costs
- Each instance's SDK polls or streams on its own schedule, so a flip is live on one instance seconds before another
- The service is a dependency on the request path, and its default rule decides what every tenant gets when it is unreachable
The decision
flowchart TD
Q1{Non engineers flip flags}
Q1 -->|No| Q2{Under ten flags no history}
Q2 -->|Yes| C[JSON column on tenant]
Q2 -->|No| T[Tenant flags table]
Q1 -->|Yes| Q3{Percent rollout or killswitch}
Q3 -->|No| T
Q3 -->|Yes| S[Flag service]
C --> E[Snapshot per job stamp output]:::accent
T --> E
S --> EWhichever column of the table wins, build flags.snapshot(tenantId, names) first and give every job table a flag_snapshot column and every output table a version column copied from its job. Then move the first flag into the store you chose, and make the second flag the one that tests whether a non-engineer can flip it without asking you.