← Guides

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.ts by dif 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 conclude records 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

StatsigdifNotes
Feature gateOne 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 conditionsaudience.include, audience.excludeOne 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 configNo equivalentPut the values in the branch functions at the dif() call site. Changing them is a deploy.
Experiment, groups, parametersOne file, variants[] with id and weightWeights must sum to 100. Parameter values live in code, keyed by variant id.
Layer (universe)exclusion_group, or disjoint audience.include valuesNot the same thing. See Layers below. There are no layer parameters.
HoldoutNo object. An attribute plus audience.excludeSee Holdouts below. Nothing applies it for you.
Environment tierNo equivalentNo environment field in the schema.
Automatic exposure on checkGate / getExperimentExposureEvent passed to dif/events/exposure.tsFired once per experiment and user for the lifetime of the page's module. A flag fires exposures too.
logEventdif.track(metric, props)Delivered to dif/events/track.ts in custom mode.
Primary and guardrail metricsmetrics.primary, metrics.guardrailsNames only. The core does not check them against an analytics source and does not watch guardrails.
Seeing one variant on purposedif qa --force, the ?_dif= URL paramPer browser. A forced view fires no exposure.
Results in the consoleNot in the coreYour warehouse, or Dif Cloud.
Ship decisiondif concludeMoves 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.ts

dif 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
---
draft
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 clears

When 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, an owner email, and "off" / "on" quoted.
  • Every attribute used in an audience is in audience_attributes and has a file in dif/audiences/.
  • dif validate runs in CI and exits 0. "prebuild": "dif build" is in package.json.
  • Each checkGate, getExperiment and getLayer call is replaced. dif validate prints W001 for a dif("id") call in a .ts, .tsx, .js or .jsx file that names no active experiment.
  • dif/events/exposure.ts writes 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.