Step-by-step guide to migrating your feature flags from LaunchDarkly to Flaggr
Last updated October 25, 2026
Migrate from LaunchDarkly
This guide walks you through migrating feature flags from LaunchDarkly to Flaggr.
Concept Mapping
| LaunchDarkly | Flaggr | Notes |
|---|---|---|
| Project | Project | 1:1 mapping |
| Environment | Environment | Same concept |
| Feature flag | Flag | Same concept |
| Context kind | Targeting context | Flaggr uses flat key-value context |
| Segment | Cohort | Rule-based or static audience |
| Targeting rule | Targeting rule | Similar operator set |
| Percentage rollout | Percentage rollout | Deterministic hashing |
| Experiment | Experiment | Both support A/B testing |
| Relay Proxy | Self-hosted instance | Self-hosting is available on request (Self-Hosting) |
Step 1: Export Flags from LaunchDarkly
Use the LaunchDarkly API to export your flag configurations:
curl -s -H "Authorization: YOUR_LD_API_KEY" \
"https://app.launchdarkly.com/api/v2/flags/YOUR_PROJECT" | jq '.items'Step 2: Create a Flaggr Project
- Sign in at flaggr.dev/console
- Create a new project matching your LaunchDarkly project
- Create services for each application that evaluates flags
Step 3: Map Flag Types
| LaunchDarkly Type | Flaggr Type |
|---|---|
| Boolean | boolean |
| String | string |
| Number | number |
| JSON | object |
| Multivariate | Use variants on any type |
Step 4: Import Flags
Use the Flaggr import API or the dashboard import feature:
# Export from Flaggr format
curl -s -H "Authorization: Bearer fgr_your_token" \
"https://flaggr.dev/api/flags/export?serviceId=YOUR_SERVICE" > flags.json
# Or create flags via API
curl -X POST https://flaggr.dev/api/flags \
-H "Authorization: Bearer fgr_your_token" \
-H "Content-Type: application/json" \
-d '{
"key": "checkout-v2",
"name": "Checkout V2",
"type": "boolean",
"defaultValue": false,
"serviceId": "YOUR_SERVICE",
"projectId": "YOUR_PROJECT"
}'Or let Flaggr map the LaunchDarkly export for you with POST /api/migrate/launchdarkly. It takes { "serviceId", "ldExport", "envMap", "apply" }: without apply: true it is a dry run that returns the mapped flags, warnings and refusals; with it, it creates the flags and lists refused ones under failures. The console's Migrate from LaunchDarkly dialog shows the same: the refused flags and why. Each flag is checked like a single create (write access in its environment; environments that require approval are refused per flag with code: APPROVAL_REQUIRED), and a flag Flaggr can't serve the way LaunchDarkly does is refused with code: FLAG_NOT_REPRESENTABLE (see Flags Flaggr refuses).
envMap maps a LaunchDarkly environment key to a Flaggr environment ({ "ld-prod": "production" }). development, staging and production map to themselves, unless envMap names them, and a LaunchDarkly environment that envMap doesn't name (and isn't one of those) is skipped, with a warning. The environment a flag is created in is therefore the envMap value for its LaunchDarkly environment. It must be a lowercase slug of 1 to 50 characters, as POST /api/flags takes it: Production is refused with code: VALIDATION_ERROR (in refusals on a dry run, in failures when applying), and so is prod env, or any other value that isn't a slug of lowercase letters, numbers, hyphens and underscores that starts with a letter. The message names the fix: The environment "Production" isn't a valid Flaggr environment (Environment must be a lowercase slug (letters, numbers, hyphens, underscores)), and POST /api/flags refuses a flag in it. It comes from envMap: change its value there, for example to "production", and migrate again. The other flags are migrated meanwhile. Like POST /api/flags, the migration doesn't check that the environment is one the project has defined: a typo such as prod creates the flag in an environment of that name. Before 0.5.0 the flag was created under whatever envMap gave, Production included.
LaunchDarkly flags whose key is reserved in Flaggr (bulk, create-from-nl, evaluate, evaluate-debug, export, import, stream) are listed in refusals with code: FLAG_KEY_RESERVED and aren't created. Rename them in LaunchDarkly, or create them in Flaggr under another key.
LaunchDarkly keys can hold a . or start with a digit. Flaggr flag keys start with a letter, hold only letters, digits, - and _, and have at most 100 characters, as POST /api/flags takes them. A flag with another key can be stored, read, updated, toggled and deleted, and bulk OFREP (POST /api/ofrep/v1/evaluate/flags) and POST /api/flags/evaluate/batch on https://flaggr.dev evaluate it. But single-flag OFREP (POST /api/ofrep/v1/evaluate/flags/{key}, on https://flaggr.dev and on https://api.flaggr.dev), POST /api/flags/evaluate and evaluate-debug on https://flaggr.dev (POST /api/flags/evaluate on https://api.flaggr.dev takes any key), the bulk update and delete routes (PATCH and DELETE /api/flags/bulk) and both imports refuse it, so an application couldn't evaluate it flag by flag through OFREP and its export wouldn't import. (Single-flag OFREP takes keys of up to 256 characters, so a key over 100 characters fails on the other routes listed, but not there.) The migration refuses such a flag, in every environment it has, with code: VALIDATION_ERROR (in refusals on a dry run, in failures when applying), and the message says how to rename it: The key "my.flag" isn't a valid Flaggr flag key (Flag key must start with a letter and contain only letters, numbers, hyphens, and underscores), and OFREP's single-flag evaluation, flaggr.dev's POST /api/flags/evaluate and both imports refuse a flag with such a key. Rename it in the export, for example to "my-flag", change the key in the code that evaluates it, and migrate again. The example is a key Flaggr takes that stays close (checkout.v2 becomes checkout-v2, 2024-sale becomes flag-2024-sale; a key over 100 characters gets none). Rename the flag's key in the export (and in the code that evaluates it by that key), then migrate again; the other flags in the export are migrated meanwhile.
Step 5: Migrate Targeting Rules
In each environment, a LaunchDarkly flag serves its off variation while it is off. While it is on, it serves the variation of the individual target that lists the context, else of the first rule whose clauses all match, else its fallthrough (a variation, or a percentage rollout). The Flaggr flag does the same, and POST /api/migrate/launchdarkly maps each part to its counterpart:
| LaunchDarkly | Flaggr |
|---|---|
| The flag is off and serves its off variation | The flag is disabled and serves its default value, set to the off variation's value |
| Individual targets | Overrides, which Flaggr checks before the rules |
| Rules, the first match wins | Targeting rules, in order, the first match wins |
| Fallthrough: a variation | The default value (a boolean flag that is on has a last rule for everyone instead when the fallthrough is false: see Boolean flags) |
| Fallthrough: a percentage rollout | Variant weights, whole percentages that add up to 100 (a boolean flag has rules for everyone instead) |
LaunchDarkly targeting rules map to Flaggr targeting rules:
// LaunchDarkly rule
{
"clauses": [
{ "attribute": "plan", "op": "in", "values": ["enterprise", "pro"] }
],
"variation": 0
}
// Flaggr equivalent
{
"id": "ld-0",
"conditions": [
{ "property": "plan", "operator": "in", "value": ["enterprise", "pro"] }
],
"conditionOperator": "and",
"value": true,
"variant": "variant-0"
}A rule serves its variation's value and names the variation as its variant (variant-<index> when the variation has no name). The name is a label on the value: an environment has variants only when its fallthrough is a percentage rollout and the flag isn't a boolean, so with a fixed fallthrough every context that no rule matches gets the default value, with reason DEFAULT, and the console's rule editor opens such a rule as the value it serves.
A clause matches in LaunchDarkly when any of its values does, and a rule when all of its clauses do, so a clause with several values is an OR inside the AND of the rule. Flaggr's rule is conditions that must all match, or condition groups (the conditions of a group must all match) of which any one must:
// LaunchDarkly rule: either domain, and the pro plan
{
"clauses": [
{ "attribute": "email", "op": "endsWith", "values": ["@acme.com", "@acme.org"] },
{ "attribute": "plan", "op": "in", "values": ["pro"] }
],
"variation": 0
}
// Flaggr equivalent: one group per domain, each with the plan condition too
{
"id": "ld-1",
"conditions": [],
"groups": [
{ "conditions": [
{ "property": "email", "operator": "ends_with", "value": "@acme.com" },
{ "property": "plan", "operator": "in", "value": ["pro"] }
] },
{ "conditions": [
{ "property": "email", "operator": "ends_with", "value": "@acme.org" },
{ "property": "plan", "operator": "in", "value": ["pro"] }
] }
],
"groupOperator": "or",
"value": true,
"variant": "variant-0"
}The control plane, @flaggr/evaluator and the data plane at https://api.flaggr.dev all match groups this way. POST /api/migrate/launchdarkly maps rules like this, and its warnings list what you should check:
- Clause values.
inkeeps its values as one list, also when it has a single value (["enterprise"]: a value that isn't a list never matchesin). Another operator with several values, such asendsWith, becomes one condition group per value. When the rule has other clauses, each group carries them too, so (a OR b) AND c becomes two groups,a AND candb AND c. A clause with no values never matches in LaunchDarkly, and its rule is skipped with a warning. A rule that would need more than 1,000 condition groups is refused. - Negation. A negated
inbecomesnot_in, and a negatedsegmentMatchbecomesnot_in_cohortfor each segment (with no segments it matches every context, as it does in LaunchDarkly).inandnot_inmatch a context only when it has the attribute, as LaunchDarkly's do: a context without it matches neither. (A context that has the attribute set tonullis matched bynot_inon the control plane and the data plane, where LaunchDarkly countsnullas missing.) Flaggr has no negated version of the other operators, so a flag with one is refused (see below). - Attributes. A clause that has a context kind reads its attribute as a LaunchDarkly attribute reference:
/planis the attributeplan, and~1and~0stand for/and~in a name (/billing~1planisbilling/plan). Flaggr's context is flat, so a reference to a nested attribute such as/address/cityis refused, as is one that isn't a valid reference. A clause without a context kind reads its attribute as a plain name. The attributekeyis the context key, and becomes a condition ontargetingKey. The attributekindis the context's kind, which for every context Flaggr evaluates isuser:kind in ["user"]matches every context, andkindwith another operator is refused. - Dates. A date clause given in epoch milliseconds becomes the ISO date Flaggr compares. LaunchDarkly also accepts a date as epoch milliseconds in a context, but the data plane (
https://api.flaggr.dev) and@flaggr/evaluatorread a date in a context only as a string: send an RFC 3339 string such as2020-01-01T00:00:00Z. The control plane (https://flaggr.dev) reads both. Every flag with abeforeorafterclause has a warning that says so. (Flaggr also reads a bare date such as2019-06-01, which LaunchDarkly doesn't.) - Context values. Flaggr compares a context's attribute with a condition's value as the context sends it. An attribute that is a list never matches (LaunchDarkly matches it when any of its elements does). The data plane and
@flaggr/evaluatorcompare a number and a string by their text, soage30matchesin ["30"]there, where the control plane and LaunchDarkly don't match it. Send each attribute as the type the clauses have, and a single value, not a list. - Rules without clauses. A rule with no clauses matches every context in LaunchDarkly, and comes over as a rule with the match-all condition (
targetingKeynot_equals__flaggr_rollout_match_all__, which no key equals), which the console shows as Everyone. A rule with no conditions matches everyone on the control plane and in@flaggr/evaluator0.3.0, but nobody on a data plane from before Flaggr 0.5.0 (https://api.flaggr.devuntil it is updated), and every version reads the match-all condition the same way (see Targeting Rules). A rule whose clauses all hold (kind in ["user"]) comes over the same way. - A rule's percentage rollout. A Flaggr rule serves one value to a share of the contexts it matches, and the rest fall through to the next rule. So a rollout becomes one rule per variation, smallest share first, each with the cumulative percentage: a 50/30/20 rollout is three rules, at 20%, 50% and the last one without a percentage. Every context the clauses match gets one of the rollout's variations, and one without a
targetingKey, which can't be bucketed, gets the largest share's. - A fallthrough percentage rollout. It becomes the flag's variant weights, and a context without a
targetingKey(which can't be bucketed) gets the default value, which is then the off variation's, or the value of the largest share when the flag has no off variation. A rollout that gives one variation every bucket is that variation. A boolean flag's rollout is rules instead: see Boolean flags. - Whole percentages. LaunchDarkly weights are thousandths of a percent (100000 in all). Flaggr's percentages and variant weights are whole numbers, so the weights are rounded to whole percentages that still add up to 100, and the warning shows each change (
33.333% → 33%, 33.334% → 34%). A share under 1% can round to 0%, and the warning then says that variation is never served. - Bucketing. Flaggr hashes
targetingKeywith its own function, so a rollout gives each variation the same share of contexts as in LaunchDarkly, but not the same contexts: some users land in another variation after the move. Each rollout's warning says so. - The off variation. Flaggr serves one default value to a flag that is off and to the contexts no rule matches, where LaunchDarkly has an off variation and a fallthrough. A flag that is off gets its off variation's value as its default; one that is on gets its fallthrough's. Where the two differ, the default can't be both, and the warning says what turning the flag on, or off, in Flaggr then serves: change the default value when you do. A flag with no off variation gets a warning too: LaunchDarkly serves the application's own default while the flag is off. A boolean flag is different: see Boolean flags.
- Segments. A
segmentMatchclause becomes anin_cohortcondition for each of its segments (ORed). Create the cohort in Flaggr and link it: only the control plane athttps://flaggr.devevaluates cohort conditions, andhttps://api.flaggr.devnever matches them. - Individual targets. They become overrides of at most 100 targeting keys each; a longer list becomes several overrides that serve the same value, and the warning says how many. A flag takes at most 50 overrides, which is 5,000 targeted users. The preview warns when a flag's targets need more (
its targets need 51 overrides, and a flag takes at most 50 (5000 targeted users)), and the flag is then refused withcode: VALIDATION_ERROR: target those users with a cohort rule instead. - Tags. A flag takes at most 20 tags of 1 to 50 characters. The rest are dropped, and the warning counts them (
4 of its 24 tags dropped). Tags don't change what a flag serves, so the flag is still imported. - Names and descriptions. A flag's name takes at most 200 characters and its description at most 1,000 (characters, not bytes), as
POST /api/flagstakes them. A longer one doesn't stop the flag: a name over 200 characters or a description over 1,000 is trimmed to fit, with a warning (its name has 250 characters, and a flag's name takes at most 200 — trimmed to fit (rename the flag in Flaggr to change it)). The cut falls at a whole character: an emoji made of several characters, such as a flag, is dropped whole rather than cut in two. Like tags, they don't change what a flag serves, so the flag is still imported. A flag with no name, or an empty one, is named by its key. - On. An environment's
onmust betrueorfalse. A name or description that isn't a string, or anonthat is neithertruenorfalse, is refused withcode: VALIDATION_ERRORand the imports' message (Flag name must be a string,Description must be a string,Enabled must be true or false), not guessed at: the string"false", a number and an object are all refused, where before 0.5.0onwas read as truthy or falsy, so the string"false"turned the flag on. A flag whose environment has noon(a missingonis off) or anullone is created off. - Checks. Each mapped flag's key, environment, name, description, enabled flag, rules, variants and overrides are then checked as
POST /api/flagschecks them. A flag that fails, such as one with a clause attribute that isn't a string in the export, is refused withcode: VALIDATION_ERROR, and one the Go data plane couldn't load withcode: FLAG_NOT_SERVABLE(more than 100 rules, 1,000 condition groups or 10,000 conditions across a flag's rules, for example: two rules of 600 condition groups each pass the check of a single rule, and fail this one). Both are inrefusalson a dry run, and infailureswhen applying.
Boolean flags
Flaggr's switch turns a boolean flag on or off by writing enabled and defaultValue together (on is both true, off is both false), and the console shows a boolean as on when its default value is true. LaunchDarkly's switch only turns targeting on or off, and keeps the fallthrough. A boolean flag that is on in LaunchDarkly with a fallthrough of false, a target and a rule serving true, the most common kind, would otherwise show as Off in the console while it serves true to its target and rule, and turning it On there would serve true to everyone. So that Flaggr's switch does what LaunchDarkly's does, a boolean flag comes over like this:
- On in LaunchDarkly:
enabled: trueanddefaultValue: true, so the console shows it as On. The individual targets and the rules come first, as in LaunchDarkly. A fallthrough oftrueis then the default value. A fallthrough offalseis a last rule,ld-fallthrough, for everyone (the match-all condition) servingfalse. A fallthrough that is a percentage rollout is rules for everyone after the others,ld-fallthrough-1andld-fallthrough-2, the smaller share first with its cumulative percentage and the other taking the rest: a context without atargetingKey, which can't be bucketed, gets the larger share's value. - Off in LaunchDarkly:
enabled: falseand the off variation's value as the default value, which for the usualfalseshows it as Off. The rules for the fallthrough are there too, so turning it On in the console serves the targets, the rules and then the fallthrough, as turning targeting on in LaunchDarkly does. - Turning it off in the console serves
falseto everyone. That is LaunchDarkly's off variation when it isfalse. When the off variation istrue, or the flag has none, the flag's warning says so: set the default value when you turn the flag off.
The last rule is the fallthrough, so the console's Fallthrough panel shows the default value true as unreachable ("Rule N serves everyone", N being the last rule), and the contexts it serves are reported with reason TARGETING_MATCH, not DEFAULT. The last rule serves everyone, so rollout plans and canaries don't start behind it (409 ROLLOUT_RULE_SHADOWED). To start a Flaggr rollout on such a flag, first set its default value to false (the last rule keeps serving false), then delete the rule. In the other order everyone gets true in between.
Flags Flaggr refuses
When Flaggr can't serve an environment of a flag the way LaunchDarkly does, it refuses that flag rather than create it different. The refusal has code: FLAG_NOT_REPRESENTABLE and lists every reason; it is in refusals on a dry run (the flag's row has refused), in failures when applying, and in the console's dialog. The flag's other environments are mapped on their own. A flag is refused when it has:
- an operator Flaggr doesn't have (
semVerEqual,semVerLessThan,semVerGreaterThan). Dropping the clause would widen the rule, and dropping the rule would change who gets what. - a negated clause on any operator but
inandsegmentMatch. - prerequisite flags: LaunchDarkly serves the off variation when one fails, and the data plane doesn't evaluate prerequisites.
- clauses, individual targets or rollouts of a context kind other than
user, or a rollout bucketed by an attribute other than the key. Flaggr's context has one kind, and buckets rollouts bytargetingKey. - a clause on a nested attribute (
/address/city), on an attribute reference that isn't valid, or onkindwith an operator other thanin. - a rule, fallthrough, target or off variation that names a variation the flag doesn't have, or a rollout whose weights aren't numbers that add up to 100000.
- a rule that serves neither a variation nor a percentage rollout.
- a rule that serves a variation whose value is
null: a Flaggr rule without a value serves the flag's default value. - a boolean flag with a variation that isn't
trueorfalse.
Change such a flag in LaunchDarkly and import it again, or create it in Flaggr.
Operator Mapping
| LaunchDarkly | Flaggr | With several values | Negated |
|---|---|---|---|
in | in | One list | not_in |
endsWith | ends_with | One condition group per value | The flag is refused |
startsWith | starts_with | One condition group per value | The flag is refused |
matches | regex | One condition group per value | The flag is refused |
contains | contains | One condition group per value | The flag is refused |
lessThan | less_than | One condition group per value | The flag is refused |
lessThanOrEqual | lte | One condition group per value | The flag is refused |
greaterThan | greater_than | One condition group per value | The flag is refused |
greaterThanOrEqual | gte | One condition group per value | The flag is refused |
before | before | One condition group per value | The flag is refused |
after | after | One condition group per value | The flag is refused |
segmentMatch | in_cohort (create the cohort in Flaggr and link it) | One condition per segment, ORed | not_in_cohort for each segment |
semVerEqual, semVerLessThan, semVerGreaterThan | None | The flag is refused |
Step 6: Update SDK Integration
Before (LaunchDarkly)
import * as ld from 'launchdarkly-js-client-sdk'
const client = ld.initialize('YOUR_CLIENT_ID', { key: 'user-123' })
await client.waitForInitialization()
const value = client.variation('checkout-v2', false)After (Flaggr)
import { createFlaggr } from '@flaggr/sdk'
const client = createFlaggr({
serviceId: 'web-app',
apiKey: 'fgr_your_token',
})
const value = await client.getBooleanValue('checkout-v2', false, {
targetingKey: 'user-123',
})Step 7: Parallel Running
Run both systems in parallel during migration:
// Evaluate from both, log discrepancies
const ldValue = ldClient.variation('checkout-v2', false)
const flaggrValue = await flaggrClient.getBooleanValue('checkout-v2', false)
if (ldValue !== flaggrValue) {
console.warn('Flag mismatch', { flag: 'checkout-v2', ld: ldValue, flaggr: flaggrValue })
}
// Use Flaggr value (or LD during transition)
return flaggrValueStep 8: Decommission LaunchDarkly
Once all flags are migrated and verified:
- Remove LaunchDarkly SDK dependencies
- Delete LaunchDarkly environment variables
- Cancel LaunchDarkly subscription