Migrating from Statsig to dif
This guide moves feature gates, experiments and layers out of the Statsig console and into
Markdown files under dif/experiments/. Every dif command and file below was
run on dif 0.6.3. The Statsig side follows the public docs at docs.statsig.com as read on 2026-10-07.
Why teams are asking now: on September 2, 2025 Statsig announced a definitive agreement to join OpenAI. On May 5, 2026 Amplitude announced that it is taking over the Statsig brand and customers and will maintain the platform. Statsig still runs. For the product comparison, pricing and SDK counts, read the Statsig alternative post. This page is only the how.
Who should not migrate
dif is a smaller tool than Statsig. Stay where you are if any of these hold.
- You change gates from the console. dif has no remote toggle. Weights,
variants and audiences are compiled into
dif/generated/client.tsbydif build. Turning a flag off means editing the file, merging, building and deploying. If your incident runbook says "flip the gate", keep a tool that can flip it. - You rely on dynamic configs. dif variants are string ids. There are no JSON payloads and no values you can change without a deploy.
- You evaluate flags outside JavaScript. The SDK is JavaScript and TypeScript only. There is no Python, Go, Java, Ruby, Swift or Kotlin SDK.
- You need Statsig to compute your results. The open-source core has no
stats engine and no results view. It does not compute lift, confidence or sample size.
dif concluderecords the decision text you give it. You either analyse exposures in your own warehouse or pay for Dif Cloud, which is a separate hosted product. - You use rule conditions beyond equality. A dif audience supports equality and list membership on declared attributes. No greater-than, no version comparisons, no regex, no nested AND/OR groups.
- You depend on per-environment rules. There is no environment field in a dif file. The same compiled client ships to every environment.
Who this is for
A team with a JavaScript or TypeScript app, a deploy pipeline fast enough that "merge and deploy" is an acceptable way to change a rollout, and either a warehouse that already holds exposure events or a willingness to add Dif Cloud for results. If you want the flag change in the same pull request as the code it gates, the rest of this page is the work.
Concept mapping
| Statsig | dif | Notes |
|---|---|---|
| Feature gate | One file in dif/experiments/active/ with two variants, "off" and "on" | The pass percentage becomes the weight of "on". Quote
both ids or YAML reads them as booleans. The first variant is the fallback. |
| Gate rules and conditions | audience.include, audience.exclude | One audience per file, no ordered rule list. Each attribute is declared under audience_attributes in dif/config.yaml and resolved by dif/audiences/<name>.ts. |
| Dynamic config | No equivalent | Put the values in the branch functions at the dif() call site.
Changing them is a deploy. |
| Experiment, groups, parameters | One file, variants[] with id and weight | Weights must sum to 100. Parameter values live in code, keyed by variant id. |
| Layer (universe) | exclusion_group, or disjoint audience.include values | Not the same thing. See Layers below. There are no layer parameters. |
| Holdout | No object. An attribute plus audience.exclude | See Holdouts below. Nothing applies it for you. |
| Environment tier | No equivalent | No environment field in the schema. |
Automatic exposure on checkGate / getExperiment | ExposureEvent passed to dif/events/exposure.ts | Fired once per experiment and user for the lifetime of the page's module. A flag fires exposures too. |
logEvent | dif.track(metric, props) | Delivered to dif/events/track.ts in custom mode. |
| Primary and guardrail metrics | metrics.primary, metrics.guardrails | Names only. The core does not check them against an analytics source and does not watch guardrails. |
| Seeing one variant on purpose | dif qa --force, the ?_dif= URL param | Per browser. A forced view fires no exposure. |
| Results in the console | Not in the core | Your warehouse, or Dif Cloud. |
| Ship decision | dif conclude | Moves the file to dif/experiments/concluded/ and appends one line to
the surface's ## Learnings. |
Before you start
dif buckets with its own hash: SHA-256 of a per-experiment salt plus the user id, mod 10,000. A user who was
in a Statsig test group will not land in the same dif variant. So do not move an experiment
that is still collecting data. Conclude it in Statsig, or let it finish there, and start
the next one in dif. Gates that sit at 0% or 100% are safe to move at any time.
The starting point for the examples is this Statsig code:
import { StatsigClient } from "@statsig/js-client";
const client = new StatsigClient("client-xyz", { userID: session.userId });
await client.initializeAsync();
const search = client.checkGate("new_search") ? "new search" : "old search";
const exp = client.getExperiment("checkout_cta");
button.textContent = exp.get("button_text", "Place order");
client.logEvent("completed_checkout");1. Scaffold dif next to the Statsig SDK
Both SDKs can run in the same app while you port one gate at a time. --events custom scaffolds two handlers you own, which is what keeps exposures
flowing to your existing warehouse. --agents none skips the agent instruction
files; drop it if you want them.
$ npm install -D @dif.sh/cli
$ npm install @dif.sh/sdk
$ npx dif init --surface checkout --events custom --agents none
✓ created dif/experiments/{active,concluded}
✓ created dif/surfaces/
✓ created dif/audiences/
✓ wrote dif/config.yaml
✓ wrote dif/.gitignore
✓ wrote dif/surfaces/checkout.md
✓ wrote dif/audiences/locale.ts
✓ wrote dif/audiences/device_type.ts
✓ created dif/events/
✓ wrote dif/events/exposure.ts
✓ wrote dif/events/track.tsdif init writes one surface. A surface is a file, so add the others by hand:
create dif/surfaces/search.md and dif/surfaces/admin.md with a # Surface: search heading and a ## Learnings section. A file that
names a surface with no matching file fails with E004.
2. Port a percentage gate
The Statsig gate new_search passes 10% of users. In dif that is one file. You
can start it with dif new new-search --surface search, which writes a draft
with control and variant_a, then edit it to this:
# dif/experiments/active/new-search.md
---
id: new-search
status: active
owner: ada@acme.dev
surface: search
hypothesis: >
The rewritten search backend returns results without raising the
zero-result rate.
variants:
- id: "off"
weight: 90
- id: "on"
weight: 10
metrics:
primary: search_result_click
created: 2026-10-07
---dif new writes status: draft. dif build skips drafts,
prints 1 draft(s) skipped and still exits 0, so a gate you forgot to set to status: active returns its first branch with no error.3. Port a targeted gate
A gate with a rule such as "email ends with @acme.dev" becomes an audience on an attribute
you compute. Weight 0 on "off" and 100 on "on" means everyone in
the audience gets "on", and everyone outside it gets the first variant.
# dif/experiments/active/internal-tools.md
---
id: internal-tools
status: active
owner: ada@acme.dev
surface: admin
hypothesis: >
Employees see the admin tools. Nobody else does.
audience:
include:
- employee: true
variants:
- id: "off"
weight: 0
- id: "on"
weight: 100
metrics:
primary: admin_tool_opened
created: 2026-10-07
---The validator rejects an attribute it has not been told about:
$ npx dif validate error E006 dif/experiments/active/internal-tools.md:1:1: audience attribute `employee` is not declared in dif/config.yaml help: Add it to `audience_attributes` in your config, or remove this predicate. ✗ 1 error(s), 0 warning(s)
Declare it in dif/config.yaml, then add the resolver. Skip the resolver and dif validate fails with E008.
# dif/config.yaml
audience_attributes:
- name: locale
type: string
- name: device_type
type: enum
values: [mobile, tablet, desktop]
- name: employee
type: boolean// dif/audiences/employee.ts
import { session } from "../../src/session";
export default function resolve(): boolean {
return session.email?.endsWith("@acme.dev") ?? false;
}src/session is your app's own module. A resolver takes no arguments, so it
reads whatever your app already knows about the current user.
4. Port an experiment
Group names become variant ids. Parameter values move into code. The first variant is what a user gets when there is no user id or the audience does not match.
# dif/experiments/active/checkout-cta.md
---
id: checkout-cta
status: active
owner: ada@acme.dev
surface: checkout
hypothesis: >
"Get it today" on the pay button lifts completed_checkout.
variants:
- id: control
weight: 50
- id: get_it_today
weight: 50
metrics:
primary: completed_checkout
guardrails: [refund_rate]
created: 2026-10-07
---5. Build and swap the call sites
$ npx dif build ✓ validated 3 active experiment(s) ✓ typed client → dif/generated/client.ts ✓ audiences → dif/generated/audiences.ts ✓ events → dif/generated/events.ts ✓ context → dif/context.json
dif/generated/ is gitignored by dif/.gitignore, so CI and deploys
must run the build first. Add "prebuild": "dif build" to package.json. Then replace the Statsig calls:
import "./dif/generated/client";
import { attributes } from "./dif/generated/audiences";
import { events } from "./dif/generated/events";
import { dif } from "@dif.sh/sdk";
dif.init({
userId: () => session.userId,
attributes: () => attributes(),
events,
});
const search = dif("new-search", {
off: () => "old search",
on: () => "new search",
});
const cta = dif("checkout-cta", {
control: () => "Place order",
get_it_today: () => "Get it today",
});
button.textContent = cta();dif() returns a function. Calling it buckets the user, runs the matching
branch and fires one exposure. There is no initializeAsync to await because
nothing is fetched. With no user id (userId returns null) the SDK
returns the first variant and fires no exposure, and the core SDK does not mint an
anonymous id for you. If Statsig was bucketing logged-out visitors on a stable id, supply
your own cookie id here.
6. Replace layers
A Statsig layer divides a pool of users among experiments so nobody is in two of them. dif
checks for the same collision at build time. Two active experiments on one surface fail validation until you resolve it:
$ npx dif validate error E007 dif/experiments/active/checkout-cta.md:1:1: experiments `checkout-cta` and `checkout-trust-badges` both target surface `checkout` without a shared exclusion_group, and their audiences are not provably disjoint help: Add the same `exclusion_group:` value to both, narrow one of the audiences so they're provably disjoint, or change one of their `surface:` fields. ✗ 1 error(s), 0 warning(s)
dif build runs the same check and writes nothing when it fails. There are two
fixes, and they behave differently.
Option A: exclusion_group (priority, not a split)
Add exclusion_group: checkout to both files. A user is then in at most one of
them. But the group is ordered by created date, and the earliest experiment
takes every user its audience matches:
$ npx dif validate ✓ all checks passed $ npx dif qa --user u_1 trace u_1: • checkout-cta → control (bucket 3727) • checkout-trust-badges ↛ exclusion loser (winner: checkout-cta) • internal-tools ↛ audience miss • new-search → off (bucket 2810) $ npx dif qa --user u_2 trace u_2: • checkout-cta → get_it_today (bucket 6994) • checkout-trust-badges ↛ exclusion loser (winner: checkout-cta) • internal-tools ↛ audience miss • new-search → off (bucket 4941)
checkout-trust-badges gets nobody, because checkout-cta was
created a day earlier and has no audience restriction. An exclusion group fits a narrow
experiment (say locale: de-DE) stacked in front of a general one. It does not
give each experiment a share of traffic the way a layer allocation does.
Option B: a slot attribute (a real split)
To give each experiment its own slice, declare an attribute that splits users, and include a different value in each file. The validator can prove the two audiences never overlap, so no group is needed.
# dif/config.yaml, appended to audience_attributes
- name: checkout_slot
type: enum
values: [a, b]// dif/audiences/checkout_slot.ts
import { session } from "../../src/session";
// Splits user ids into two fixed halves. Your hash, not dif's.
export default function resolve(): "a" | "b" | null {
if (!session.userId) return null;
let h = 0;
for (const ch of "checkout-slot:" + session.userId) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
return h % 2 === 0 ? "a" : "b";
}# dif/experiments/active/checkout-cta.md
audience:
include:
- checkout_slot: a
# dif/experiments/active/checkout-trust-badges.md
audience:
include:
- checkout_slot: b$ npx dif validate ✓ all checks passed $ npx dif qa --user u_1 --attr checkout_slot=a trace u_1: • checkout-cta → control (bucket 3727) • checkout-trust-badges ↛ audience miss • internal-tools ↛ audience miss • new-search → off (bucket 2810) $ npx dif qa --user u_1 --attr checkout_slot=b trace u_1: • checkout-cta ↛ audience miss • checkout-trust-badges → badges (bucket 5957) • internal-tools ↛ audience miss • new-search → off (bucket 2810)
The split is your code, so its evenness is yours to verify. Disjointness is proved from include only: an include: checkout_slot: a next to an exclude: checkout_slot: a still fails with E007. Conflict
detection is per surface; two experiments on different surfaces are never
flagged, even if they touch the same screen.
7. Replace holdouts
Statsig applies a holdout for you: held-back users fail gates and get default experiment values. dif has no holdout object. The closest thing that works is an attribute you resolve, excluded in every file the holdout covers.
// dif/audiences/holdout.ts
import { session } from "../../src/session";
// 5% of user ids, picked by your own hash. dif does not compute this.
export default function resolve(): boolean {
if (!session.userId) return false;
let h = 0;
for (const ch of "holdout-2026q4:" + session.userId) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
return h % 100 < 5;
}# added to every experiment file the holdout covers
audience:
include:
- checkout_slot: a
exclude:
- holdout: true$ npx dif qa --user u_1 --attr checkout_slot=b --attr holdout=true trace u_1: • checkout-cta ↛ audience miss • checkout-trust-badges ↛ audience miss • internal-tools ↛ audience miss • new-search → off (bucket 2810)
A held-out user gets the first variant of each covered experiment and fires no exposure for
it. Three things to know. Declare holdout as a boolean in dif/config.yaml. Nothing adds the exclude to new files, so a
reviewer has to. And dif records nothing about the holdout group, so send the attribute to
your warehouse yourself if you want to compare the two populations. For a single shipped
change, the simpler form is in the holdout experiment post: keep the file active
with weights 5 and 95.
8. Keep exposures going to your warehouse
With events: mode: custom in dif/config.yaml, every exposure is
handed to the default export of dif/events/exposure.ts and every dif.track() call to dif/events/track.ts. Forward them to the
client that already feeds your warehouse:
// dif/events/exposure.ts
import type { ExposureEvent } from "@dif.sh/sdk";
import { analytics } from "../../src/analytics";
export default function exposure(event: ExposureEvent): void {
analytics.track("experiment_exposure", {
experiment: event.experiment,
variant: event.variant,
user_id: event.user_id,
surface: event.surface,
fired_at: new Date(event.fired_at).toISOString(),
source: event.source,
});
}// dif/events/track.ts
import type { MetricEvent } from "@dif.sh/sdk";
import { analytics } from "../../src/analytics";
export default function track(event: MetricEvent): void {
analytics.track(event.metric, { value: event.value, ...event.props });
}src/analytics stands for whatever you already use (Segment, Rudderstack, your
own endpoint). dif bundles no integrations; these two functions are the whole interface.
Output from the test app, one line per exposure:
analytics.track experiment_exposure {"experiment":"new-search","variant":"off","user_id":"u_1","surface":"search","fired_at":"2026-10-07T22:31:09.059Z","source":"@dif.sh/sdk@0.6.3"}
analytics.track experiment_exposure {"experiment":"checkout-cta","variant":"control","user_id":"u_1","surface":"checkout","fired_at":"2026-10-07T22:31:09.059Z","source":"@dif.sh/sdk@0.6.3"}If your Statsig exposures already land in a warehouse table, map these fields onto its
columns so old and new rows can be queried together. The experiment name will differ
(checkout_cta in Statsig, checkout-cta here), which is useful: it
keeps the two assignment schemes apart. Two limits: exposure dedupe is in memory and not
persisted, so expect one row per user per page load, and dedupe in SQL. And nobody computes
lift from these rows unless you do. See the events reference for the full event shapes.
9. QA, then conclude
Force a variant in a browser without touching the file:
$ npx dif qa --user u_1 --attr checkout_slot=b --force checkout-trust-badges=badges --preview-url https://staging.example.com/checkout
trace u_1:
• checkout-cta ↛ audience miss
• checkout-trust-badges → badges (forced)
• internal-tools ↛ audience miss
• new-search → off (bucket 2810)
preview: https://staging.example.com/checkout?_dif=checkout-trust-badges%3Dbadges
open in the app to force these variants — fires no exposure; ?_dif=off clearsWhen a gate reaches 100% and the old branch is deleted, close the file:
$ npx dif conclude new-search --decision "Shipped on at 100%." → moved dif/experiments/concluded/2026-10-new-search.md → drafted ## Decision block → appended to dif/surfaces/search.md "Shipped on at 100%."
Cutover checklist
- Export or screenshot every Statsig gate, experiment, layer and holdout you still read in code. Dead ones do not get a file.
- No running experiment is moved mid-flight. Each one is concluded in Statsig or restarted in dif under a new id.
- Every ported file has
status: active, anowneremail, and"off"/"on"quoted. - Every attribute used in an audience is in
audience_attributesand has a file indif/audiences/. dif validateruns in CI and exits 0."prebuild": "dif build"is inpackage.json.- Each
checkGate,getExperimentandgetLayercall is replaced.dif validateprintsW001for adif("id")call in a.ts,.tsx,.jsor.jsxfile that names no active experiment. dif/events/exposure.tswrites to your warehouse and you have seen rows arrive for one flag.- The on-call runbook no longer says "turn off the gate in the console". It says which file to edit and how long a deploy takes.
- Anyone who changed gates in the console knows a rollout change is now a pull request.
- Last: remove
@statsig/js-client, delete the client key from your env, and archive the gates in Statsig.
The frontmatter fields are listed in the file format reference, and the eight verbs in the CLI reference. Coming from Eppo instead? The Eppo guide covers the same steps.