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

CriterionJSON column on the tenanttenant_flags tableFlag service
Who flips itAn engineer, with a migration or a database consoleOps, through an admin screen that writes a rowProduct, through the vendor's dashboard
Record of the changeNone unless you add a history table yourselfOne row per change, with changed_by and changed_atKept by the service, exported on request
Read costNothing extra: the tenant row is already loadedOne indexed query per request, or a cache in front of itA network call on cold start, then the SDK's local cache
Agreement between instancesEvery instance reads the same row on each requestThe same, until you add a cache with a TTLEach SDK polls on its own interval, so instances disagree for seconds
Turning it on for many tenantsEdit the JSON on each rowOne UPDATE, or a batch insert by tenant segmentA percentage of tenants by tenant id, or a segment rule
Meaning for a new tenantThe column defaultA missing row, whose meaning you must defineThe service's default rule
Retiring a flagA migration that removes the field from every rowDelete the rows and the code in one releaseArchive 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.

TypeScript
// 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 --> E
The store depends on who flips flags; the snapshot is built whichever store wins

Whichever 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.

Written by Md Nasim Anjum, senior full-stack engineer in Manchester. He builds payment orchestration, KYC and KYB compliance platforms and conversational AI.

Get in touchAll writingRSS

More writing