Skip to main content

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

LaunchDarklyFlaggrNotes
ProjectProject1:1 mapping
EnvironmentEnvironmentSame concept
Feature flagFlagSame concept
Context kindTargeting contextFlaggr uses flat key-value context
SegmentCohortRule-based or static audience
Targeting ruleTargeting ruleSimilar operator set
Percentage rolloutPercentage rolloutDeterministic hashing
ExperimentExperimentBoth support A/B testing
Relay ProxySelf-hosted instanceSelf-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

  1. Sign in at flaggr.dev/console
  2. Create a new project matching your LaunchDarkly project
  3. Create services for each application that evaluates flags

Step 3: Map Flag Types

LaunchDarkly TypeFlaggr Type
Booleanboolean
Stringstring
Numbernumber
JSONobject
MultivariateUse 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:

LaunchDarklyFlaggr
The flag is off and serves its off variationThe flag is disabled and serves its default value, set to the off variation's value
Individual targetsOverrides, which Flaggr checks before the rules
Rules, the first match winsTargeting rules, in order, the first match wins
Fallthrough: a variationThe 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 rolloutVariant 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. in keeps its values as one list, also when it has a single value (["enterprise"]: a value that isn't a list never matches in). Another operator with several values, such as endsWith, 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 c and b 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 in becomes not_in, and a negated segmentMatch becomes not_in_cohort for each segment (with no segments it matches every context, as it does in LaunchDarkly). in and not_in match 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 to null is matched by not_in on the control plane and the data plane, where LaunchDarkly counts null as 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: /plan is the attribute plan, and ~1 and ~0 stand for / and ~ in a name (/billing~1plan is billing/plan). Flaggr's context is flat, so a reference to a nested attribute such as /address/city is refused, as is one that isn't a valid reference. A clause without a context kind reads its attribute as a plain name. The attribute key is the context key, and becomes a condition on targetingKey. The attribute kind is the context's kind, which for every context Flaggr evaluates is user: kind in ["user"] matches every context, and kind with 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/evaluator read a date in a context only as a string: send an RFC 3339 string such as 2020-01-01T00:00:00Z. The control plane (https://flaggr.dev) reads both. Every flag with a before or after clause has a warning that says so. (Flaggr also reads a bare date such as 2019-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/evaluator compare a number and a string by their text, so age 30 matches in ["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 (targetingKey not_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/evaluator 0.3.0, but nobody on a data plane from before Flaggr 0.5.0 (https://api.flaggr.dev until 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 targetingKey with 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 segmentMatch clause becomes an in_cohort condition for each of its segments (ORed). Create the cohort in Flaggr and link it: only the control plane at https://flaggr.dev evaluates cohort conditions, and https://api.flaggr.dev never 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 with code: 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/flags takes 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 on must be true or false. A name or description that isn't a string, or an on that is neither true nor false, is refused with code: VALIDATION_ERROR and 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.0 on was read as truthy or falsy, so the string "false" turned the flag on. A flag whose environment has no on (a missing on is off) or a null one is created off.
  • Checks. Each mapped flag's key, environment, name, description, enabled flag, rules, variants and overrides are then checked as POST /api/flags checks them. A flag that fails, such as one with a clause attribute that isn't a string in the export, is refused with code: VALIDATION_ERROR, and one the Go data plane couldn't load with code: 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 in refusals on a dry run, and in failures when 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: true and defaultValue: true, so the console shows it as On. The individual targets and the rules come first, as in LaunchDarkly. A fallthrough of true is then the default value. A fallthrough of false is a last rule, ld-fallthrough, for everyone (the match-all condition) serving false. A fallthrough that is a percentage rollout is rules for everyone after the others, ld-fallthrough-1 and ld-fallthrough-2, the smaller share first with its cumulative percentage and the other taking the rest: a context without a targetingKey, which can't be bucketed, gets the larger share's value.
  • Off in LaunchDarkly: enabled: false and the off variation's value as the default value, which for the usual false shows 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 false to everyone. That is LaunchDarkly's off variation when it is false. When the off variation is true, 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 in and segmentMatch.
  • 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 by targetingKey.
  • a clause on a nested attribute (/address/city), on an attribute reference that isn't valid, or on kind with an operator other than in.
  • 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 true or false.

Change such a flag in LaunchDarkly and import it again, or create it in Flaggr.

Operator Mapping

LaunchDarklyFlaggrWith several valuesNegated
ininOne listnot_in
endsWithends_withOne condition group per valueThe flag is refused
startsWithstarts_withOne condition group per valueThe flag is refused
matchesregexOne condition group per valueThe flag is refused
containscontainsOne condition group per valueThe flag is refused
lessThanless_thanOne condition group per valueThe flag is refused
lessThanOrEquallteOne condition group per valueThe flag is refused
greaterThangreater_thanOne condition group per valueThe flag is refused
greaterThanOrEqualgteOne condition group per valueThe flag is refused
beforebeforeOne condition group per valueThe flag is refused
afterafterOne condition group per valueThe flag is refused
segmentMatchin_cohort (create the cohort in Flaggr and link it)One condition per segment, ORednot_in_cohort for each segment
semVerEqual, semVerLessThan, semVerGreaterThanNoneThe 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 flaggrValue

Step 8: Decommission LaunchDarkly

Once all flags are migrated and verified:

  1. Remove LaunchDarkly SDK dependencies
  2. Delete LaunchDarkly environment variables
  3. Cancel LaunchDarkly subscription