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 concluderecords 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.tsbydif 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
getNumericAssignmentorgetJSONAssignment, 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
| Eppo | dif | Notes |
|---|---|---|
| Flag with variations | One file in dif/experiments/active/, variants[] with id and weight | String ids only. For a boolean flag use "off" and "on", quoted, or YAML reads them as booleans. |
| Default value | The first declared variant | Returned when there is no user id or the audience does not match. |
| Feature gate allocation with targeting rules | audience.include, audience.exclude | One audience per file. Each attribute is declared under audience_attributes in dif/config.yaml and resolved by dif/audiences/<name>.ts. |
| Experiment allocation, variation weights | weight on each variant | Weights must sum to 100. |
| Traffic exposure below 100% | No field | Narrow the audience with an attribute you resolve, as in Layers below. |
| Layer (mutual exclusion) | 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. |
| Environment | No equivalent | No environment field in the schema. |
subjectKey, subjectAttributes | userId and attributes in dif.init() | Set once at init as functions, not passed at each call. |
assignmentLogger.logAssignment | The default export of dif/events/exposure.ts | Fired once per experiment and user for the lifetime of the page's module. |
| Metrics on an experiment | metrics.primary, metrics.guardrails | Names only. The core checks them against nothing. |
| Experiment analysis | Not in the core | Your warehouse, or Dif Cloud. |
| Rollout and archive | dif conclude | Moves 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.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 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
---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:
experimentis the difid. 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
allocationorfeatureFlagfield, and nosubjectAttributes. 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
experimentif 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 clearsWhen 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, 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
get*Assignmentcall is replaced.dif validateprintsW001for adif("id")call in a.ts,.tsx,.jsor.jsxfile that names no active experiment. dif/events/exposure.tswrites 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.