Moving off an in-house flag table: import, shadow, cut over

Move off a homegrown flag table in four steps: import every flag with its history, run the new SDK in shadow beside the old table so it serves the old answer and logs every difference, cut over once the log stays clean, then delete the old table after a quiet week. With Flagger.gg, the import checks itself against your export, and one environment variable, FLAGGER_MODE, switches between shadow, the new answer and the rollback.

Updated

What can go wrong in a flag migration

A flag migration fails quietly. Nothing crashes; some users just get a different answer than they did yesterday. The usual causes:

  • State drift. Someone flips a flag in the old admin page after the export, and the two systems disagree.
  • Different bucketing. The old system hashes users into percentages one way and the new one another way, so a 20% rollout reaches a different 20%.
  • Different matching rules. One system matches user ids ignoring case and the other exactly, or one treats admins or anonymous visitors differently.
  • New-flag defaults. The new service creates a flag your code registers before the import has brought over its real state.

The fix for all four is the same: copy the data exactly, then compare both systems' answers on real traffic before you trust the new one. Flagger.gg was built from the flag system that runs Sandboxer and Transformer in production, and its import, shadow mode and rollback switch exist for exactly this move.

The four steps

  1. Import flags and history. Export your flags table and its change log to Flagger.gg's import JSON (below) and import it with an admin token. Each new flag gets its source state, rules and timestamps in that environment, and every past change is copied in order with its original time and person. Import before any build with the SDK reaches production: in auto-live mode a flag your code registers first is created on.
  2. Check the import. flagger import reads every flag back and requires the state, the user, workspace and percent rules, any not-ready hold and every source event to match your export. Any difference is printed and the command exits 1. --verify runs the same check later without importing.
  3. Shadow both systems. Ship the SDK with your old evaluation passed in as local. In shadow mode the app keeps serving the old answer, asks Flagger.gg too and logs each confirmed difference. Leave it for two to three days, until the confirmed count stays at zero.
  4. Keep both sides in step. While shadowing, make no flag changes, or send every change made in the old admin page to Flagger.gg's API as well (dual-write). Otherwise the two sides will rightly differ.
  5. Cut over. Run --verify once more, set FLAGGER_MODE=flagger and redeploy or restart. Point the old admin page at the matching console URL, and send writes to Flagger.gg only.
  6. Clean up after a quiet week. Drop the old flags and events tables and delete the old evaluation and admin code. Keep the flag definitions in code: the SDK registers them at each start.

In development Public installs of the Go and Node SDKs (npm and go get) and of the CLI are in development. The import works today through the API, POST /v1/import/{project}/{env}, with an admin token made in the console.

The import file

One JSON object: your flags, and your events oldest first. States are off, admins, targets, percent and on, and rules always hold users, workspaces and a percent.

{
  "source": "my-app",
  "flags": [
    {"key": "new_checkout", "description": "Rebuilt checkout", "owner": "payments",
     "summary": "New checkout page", "state": "percent",
     "rules": {"users": [], "workspaces": [], "percent": 20},
     "createdAt": "2026-09-02T14:00:00Z", "updatedAt": "2026-10-28T16:30:00Z"}
  ],
  "events": [
    {"key": "new_checkout", "at": "2026-10-28T16:30:00Z", "by": {"email": "ana@example.com"},
     "from": {"state": "admins", "rules": {"users": [], "workspaces": [], "percent": 0}},
     "to": {"state": "percent", "rules": {"users": [], "workspaces": [], "percent": 20}},
     "note": "Admins looked fine"}
  ]
}
flagger import --project my-app --env production --from flags.json \
  --not-ready "garna=Keep off until payouts are connected"
# later, before cutover:
flagger import --verify --project my-app --env production --from flags.json
ResultWhen
createdThe key is new. It's created with the source's state in this environment, and off in the project's other environments.
updatedThe key exists, but only its code registration has touched it. It takes the source's state and rules.
skippedSomeone changed the flag in Flagger.gg (changed in Flagger), or it already matches (unchanged).
heldYou passed --not-ready key=reason. The flag carries that hold in this environment, code registration never clears it, and only a person can.

The import is one transaction and changes no state the source didn't have. Re-running it is safe: events are matched on key, time and new state, so a second run copies nothing and writes nothing.

Shadow mode and the rollback switch

Both SDKs take your old evaluation as local (Go: Local). FLAGGER_MODE then picks which answer the app serves:

FLAGGER_MODEAnswer servedFlagger.gg asked?
shadowYour old table'sYes: compared, and differences logged
flaggerFlagger.gg'sYes
localYour old table's only. This is the rollback.No

If FLAGGER_MODE is unset, the mode is shadow whenever you pass local, so a forgotten variable can't switch you over early. A misspelled mode is an error at start.

import { createFlagger } from "@flaggergg/node";

export const flagger = createFlagger({
  sdkKey: process.env.FLAGGER_SDK_KEY ?? "",
  defs: { new_checkout: { description: "Rebuilt checkout", summary: "New checkout page", owner: "payments" } },
  local: (key, ctx) => oldFlag(key, ctx), // your table's answer: served in shadow and local mode
});
await flagger.start();
flagger.shadowStats(); // {mode, compared, differed, transient, confirmed, logged, notLoaded, byKey}
flags := flagger.New(flagger.Options{
	SDKKey: os.Getenv("FLAGGER_SDK_KEY"),
	Defs:   defs,
	Local:  func(key string, sub *flagger.Subject) bool { return old.Enabled(key, sub) },
})
if err := flags.Start(ctx); err != nil { // bad options or a bad FLAGGER_MODE, never the network
	return err
}

A difference is checked again 10 seconds later, because a change reaches your old cache and Flagger.gg's stream at different speeds. If it's gone by then it counts as transient. If it's still there it counts as confirmed and logs one line, at most once an hour per key, user and pair of answers. Each line carries the key, both answers, the user id (or only its hash, if you turn ids off) and never an email. "Zero differences" means confirmed stays at 0.

To roll back after cutover, set FLAGGER_MODE=local and restart. The app answers from the old table, which you left in place until the clean-up step. No code change or data migration is needed.

Settle the rules before you shadow

Shadow mode finds differences; it's quicker to know the likely ones in advance. Flagger.gg's rules, which both SDKs and the service share through one set of test vectors:

  • An unknown flag, one checked before rules load, or any error is off.
  • Visitors with no user id see only flags that are on.
  • Admins get admins, targets and percent flags.
  • Targeted user ids match exactly; verified emails match ignoring case. UUID-shaped entries are lowercased when saved.
  • A user's percent bucket is the first 4 bytes of sha256(key + ":" + userId), big-endian, mod 100; see percentage rollouts.

If your old system hashes users differently, a partly rolled-out flag will reach different users after the move. Shadow mode will show it. Either finish those rollouts (fully on or off) before you cut over, or accept that some users will see the feature change.

FAQ

How do I migrate from a homegrown feature flag system to a flag service?

Import your flags and their history, run the new SDK in shadow beside the old table until the two agree on real traffic, switch the app to the new answer, then delete the old table after a quiet week. Keep the old table until then: it's your rollback.

Will my flag history come across?

Yes. Each event is copied with its original time, the person who made it (the email, id or name your export gives), the before and after state, and its note.

What if I run the import twice?

Nothing changes. Events are matched on key, time and new state, and a flag someone has changed in Flagger.gg since is skipped rather than overwritten.

How long should I run shadow mode?

Long enough to see every flag evaluated for real users. Two to three days with zero confirmed differences is a sensible bar.

How do I roll back after cutting over?

Set FLAGGER_MODE=local and restart. The app serves your old table's answers without asking Flagger.gg.

Ship your next change behind a flag.

Sign up, add an SDK key to your app and give your agent a token.

Sign up