← Guides

Migrating from Eppo to dif

This guide moves Eppo flags, allocations and layers into Markdown files under dif/experiments/, and keeps the assignment table in your warehouse filling. Every dif command and file below was run on dif 0.6.3. The Eppo side follows the public docs at docs.geteppo.com as read on 2026-10-07.

Why teams are asking now: Datadog announced on May 5, 2025 that it had acquired Eppo, and said Eppo would keep supporting customers as "Eppo by Datadog". A Datadog engineering post dated September 10, 2026 says Eppo's feature management became Datadog Feature Flags and its experimentation became Datadog Experiments. The Eppo docs were still online when this page was written, and nothing here says Eppo is shutting down. If you are re-evaluating anyway, this is what a move to dif involves.

Who should not migrate

dif covers less ground than Eppo. Stay where you are if any of these hold.

  • You want Eppo's analysis. The open-source core of dif 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 run your own SQL on the assignment table or pay for Dif Cloud, a separate hosted product that ingests the events the SDK posts to it.
  • You change flags without a deploy. dif has no remote toggle and no kill switch. Weights, variants and audiences are compiled into dif/generated/client.ts by dif build. Any change is an edit, a merge, a build and a deploy.
  • You use typed or JSON variations. dif variants are string ids. There is no equivalent of getNumericAssignment or getJSONAssignment, and no dynamic configuration.
  • You assign outside JavaScript. The SDK is JavaScript and TypeScript only.
  • You run contextual bandits or switchbacks. dif has neither.
  • Your flags stack several allocations with rules. A dif file has one audience with equality and list-membership predicates. No waterfall, no greater-than, no regex, no nested AND/OR.
  • You depend on environments. There is no environment field in a dif file. The same compiled client ships everywhere.

Who this is for

A team with a JavaScript or TypeScript app that already logs assignments to its own warehouse, is comfortable analysing them there, and wants the flag definition reviewed in the same pull request as the code. Eppo already asks you to own the assignment logger. That part carries over almost unchanged.

Concept mapping

EppodifNotes
Flag with variationsOne file in dif/experiments/active/, variants[] with id and weightString ids only. For a boolean flag use "off" and "on", quoted, or YAML reads them as booleans.
Default valueThe first declared variantReturned when there is no user id or the audience does not match.
Feature gate allocation with targeting rulesaudience.include, audience.excludeOne audience per file. Each attribute is declared under audience_attributes in dif/config.yaml and resolved by dif/audiences/<name>.ts.
Experiment allocation, variation weightsweight on each variantWeights must sum to 100.
Traffic exposure below 100%No fieldNarrow the audience with an attribute you resolve, as in Layers below.
Layer (mutual exclusion)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.
EnvironmentNo equivalentNo environment field in the schema.
subjectKey, subjectAttributesuserId and attributes in dif.init()Set once at init as functions, not passed at each call.
assignmentLogger.logAssignmentThe default export of dif/events/exposure.tsFired once per experiment and user for the lifetime of the page's module.
Metrics on an experimentmetrics.primary, metrics.guardrailsNames only. The core checks them against nothing.
Experiment analysisNot in the coreYour warehouse, or Dif Cloud.
Rollout and archivedif concludeMoves the file to dif/experiments/concluded/ and appends one line to the surface's ## Learnings. Shipping the winner is a code change.

Before you start

dif buckets with its own hash: SHA-256 of a per-experiment salt plus the user id, mod 10,000. A subject assigned by Eppo will not land in the same dif variant. Do not move an experiment that is still collecting data. Let it finish in Eppo and start the next one in dif. Flags at 0% or 100% are safe to move at any time.

The starting point for the examples is this Eppo code:

import * as EppoSdk from "@eppo/js-client-sdk";

await EppoSdk.init({
  apiKey: "YOUR_SDK_KEY",
  assignmentLogger: {
    logAssignment: (assignment) => analytics.track("experiment_exposure", assignment),
  },
});

const client = EppoSdk.getInstance();

const newSearch = client.getBooleanAssignment("new-search", session.userId, {}, false);

const variation = client.getStringAssignment(
  "checkout-cta",
  session.userId,
  { country: "US" },
  "control",
);

1. Scaffold dif next to the Eppo SDK

Both SDKs can run in the same app while you port one flag at a time. --events custom scaffolds two handlers you own, the counterpart of your Eppo assignment logger. --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 boolean flag on a percentage rollout

The Eppo flag new-search serves true to 10% of subjects. 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 flag you forgot to set to status: active returns its first branch with no error.

3. Port a feature gate allocation

An allocation whose targeting rule is "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 the values you used to pass as subjectAttributes on each call are read from your app state here. If a flag had several allocations, write one file per audience and make the audiences disjoint, or collapse them. There is no ordered fall-through.

4. Port an experiment allocation

Variation keys become variant ids. Put the control first: it 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 assignment 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 init 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.

6. Replace layers

An Eppo layer gives each experiment a slice of a shared pool of traffic so they do not overlap. 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 hand each experiment a percentage of the layer.

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. Eppo's own docs recommend mutual exclusion only when two overlapping treatments would critically degrade the experience. If your experiments sit on different surfaces, you may need neither option.

7. Replace holdouts

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 writes no row for the holdout group: if your holdout analysis reads assignment rows keyed by the holdout name, log those from your own code. 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 the assignment table filling

With events: mode: custom in dif/config.yaml, every exposure is handed to the default export of dif/events/exposure.ts. Eppo's docs describe an assignment table with experiment, subject, variation and timestamp columns. Map the dif event onto the same names and send it through the client your logger already uses:

// 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,
    variation: event.variant,
    subject: event.user_id,
    timestamp: new Date(event.fired_at).toISOString(),
    surface: event.surface,
  });
}

Output from the test app, one line per exposure:

analytics.track experiment_exposure {"experiment":"new-search","variation":"off","subject":"u_1","timestamp":"2026-10-07T22:30:48.114Z","surface":"search"}
analytics.track experiment_exposure {"experiment":"checkout-cta","variation":"control","subject":"u_1","timestamp":"2026-10-07T22:30:48.115Z","surface":"checkout"}

What changes in the rows:

  • experiment is the dif id. Eppo's docs say its experiment key combines the flag key and the allocation key, so new rows carry a different key from old ones. That keeps the two assignment schemes apart.
  • There is no allocation or featureFlag field, and no subjectAttributes. Add attributes yourself in the handler if you need them.
  • Exposure dedupe is in memory and not persisted, so a user produces a row per page load. Dedupe in SQL.
  • A plain flag fires exposures too. Filter by experiment if you only want experiments in the table.

Whether an Eppo or Datadog analysis can keep reading that table for dif-assigned experiments is not something we have tested, so plan on your own SQL. Metric events go through dif.track() to dif/events/track.ts:

// 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 });
}

dif bundles no integrations; these two functions are the whole interface. 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 flag 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

  • List every flag key your code still reads. Flags nobody reads do not get a file.
  • No running experiment is moved mid-flight. Each one finishes in Eppo or restarts in dif under a new id.
  • Every numeric, integer or JSON assignment has been rewritten as string variant ids with the values in code, or left in Eppo.
  • 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 get*Assignment 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 the assignment table and you have queried rows for one dif experiment.
  • Someone owns the analysis SQL, or Dif Cloud is connected. Decide which before the first dif experiment starts.
  • The on-call runbook names the file to edit and the deploy time, not a dashboard switch.
  • Last: remove @eppo/js-client-sdk, delete the SDK key from your env, and archive the flags in Eppo.

The frontmatter fields are listed in the file format reference, and the eight verbs in the CLI reference. Coming from Statsig instead? The Statsig guide covers the same steps.