Naming Conventions for Feature Flag Keys
This how-to is part of Designing a Scalable Flag Taxonomy. Inconsistent flag keys are the quiet tax that compounds across every operation: you cannot search reliably, you cannot route cleanup to the right owner, and you cannot tell from the key whether a flag is an emergency toggle or a two-week experiment. Fixing naming after 50 services have drifted is painful; enforcing it from the start costs one CI step.
This guide walks you through adopting the namespace.service.feature schema, reserving semantic prefixes, enforcing casing with a regex lint rule, mapping every key to ownership and expiry metadata, and verifying that a bad key is rejected before it ever merges.
checkout.payments.express-pay encodes domain context, service ownership, and feature intent in three dot-separated segments — each answerable by a different team role.Prerequisites
node≥ 18 orpython≥ 3.10 available in CI for the lint script.service-owners.jsonor equivalent file mapping namespace prefixes to owning teamsowner,expiry,type, andstatesupported by your provider — see flag taxonomy for the full schema
Step-by-Step Procedure
Step 1 — Adopt the namespace.service.feature key schema
Every flag key must consist of exactly three dot-separated segments: a namespace (bounded context), a service (owning team or microservice), and a feature (the capability being gated). All segments use lowercase kebab-case; no underscores, no camelCase, no abbreviations that require a decoder ring.
# Good keys
checkout.payments.express-pay # namespace=checkout, service=payments, feature=express-pay
api.search.semantic-rerank # namespace=api, service=search, feature=semantic-rerank
web.dashboard.new-nav # namespace=web, service=dashboard, feature=new-nav
# Bad keys — rejected by the lint rule
newCheckoutFlow # no structure, no ownership
checkout_v2 # snake_case, only one segment
CHK-payments-expressPay # mixed case, wrong separator
checkout.payments.enableExpressPay # camelCase in feature segment
Write this standard as a single config file that the lint script reads; do not scatter the rules across documentation and code separately.
# .flaglint.yaml
key_pattern: '^(kill|exp|ops|[a-z][a-z0-9]*)(\.[a-z][a-z0-9-]*)(\.[a-z][a-z0-9-]*)$'
max_segments: 3
segment_separator: '.'
segment_case: 'kebab'
max_key_length: 80
Pitfall: teams sometimes resist three-segment keys for short-lived experiment flags, opting for a flat key like exp-checkout-v2. Hold the line — the service segment is what makes automated ownership lookup possible, and experiments are exactly the flags that need cleanup tracking.
Step 2 — Reserve semantic prefixes for cross-cutting flag types
Three prefixes carry platform-wide meaning and must not be used for regular release flags. Reserving them lets tooling apply different rules — transport requirements, TTL policies, metadata requirements — without manual annotation.
| Prefix | Semantic | TTL policy | Required extra metadata |
|---|---|---|---|
kill. |
Emergency kill-switch; streaming transport required | None (permanent safety valve) | safe_variant, incident runbook URL |
exp. |
A/B experiment; has an analysis window and a hypothesis | analysis_window end date |
hypothesis, analysis_window |
ops. |
Operational toggle; infrastructure control | None (by design) | rationale explaining why it is permanent |
Enforce these in the same lint config:
# .flaglint.yaml (extended)
reserved_prefixes:
kill:
transport_required: streaming
extra_required_fields: [safe_variant, runbook_url]
max_ttl_days: null
exp:
extra_required_fields: [hypothesis, analysis_window]
max_ttl_days: null # bounded by analysis_window, not a TTL
ops:
extra_required_fields: [rationale]
max_ttl_days: null
default_max_ttl_days: 90 # standard release flags
A kill switch prefix flag is the one you reach for during an incident; the name and the reserved prefix make it immediately findable in the registry under pressure.
Step 3 — Enforce casing and format with a regex lint in CI
The lint rule lives in CI as a required check on any PR that touches the flags directory. It reads every key in the registry, tests it against the pattern, and exits non-zero on the first failure.
#!/usr/bin/env python3
"""flag-lint.py — validate all flag keys in a registry against the naming schema."""
import json, re, sys
from pathlib import Path
CONFIG = {
"pattern": re.compile(
r'^(kill|exp|ops|[a-z][a-z0-9]*)(\.[a-z][a-z0-9-]*)(\.[a-z][a-z0-9-]*)$'
),
"max_length": 80,
"warn_only": "--warn" in sys.argv,
}
registry = json.loads(Path("flags/registry.json").read_text())
failures = []
for key in registry:
if not CONFIG["pattern"].match(key):
failures.append(f"INVALID KEY FORMAT: {key!r}")
elif len(key) > CONFIG["max_length"]:
failures.append(f"KEY TOO LONG ({len(key)} chars): {key!r}")
if failures:
level = "WARN" if CONFIG["warn_only"] else "FAIL"
for msg in failures:
print(f"[{level}] {msg}")
if not CONFIG["warn_only"]:
sys.exit(1)
else:
print(f"All {len(registry)} flag keys passed lint.")
Run it in your CI pipeline:
# .github/workflows/flag-lint.yaml
name: Flag key lint
on: [pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: python3 scripts/flag-lint.py
Pitfall: run with --warn for one sprint before switching to hard-fail. This gives every team a chance to inventory violations and plan renames before CI starts blocking merges.
Step 4 — Map keys to owners and expiry metadata
A key that passes the format lint is still dangerous if no one owns it and no system knows when to remove it. Every flag must be accompanied by a metadata sidecar that records owner (team, not individual), expiry date, type, lifecycle state, and the safe default variant. See flag taxonomy for the full schema. For this step, enforce the minimum viable set:
{
"checkout.payments.express-pay": {
"state": "ENABLED",
"variants": { "on": true, "off": false },
"defaultVariant": "off",
"metadata": {
"owner": "payments-team",
"type": "release",
"created": "2026-06-20",
"expiry": "2026-08-20",
"state": "active",
"ticket": "PAY-1234"
}
}
}
Add a second CI check that validates metadata against a JSON Schema (the full schema is in the flag taxonomy guide). The lint from Step 3 and this metadata check together form a two-layer gate: correct format, then correct content.
# CI: lint keys, then validate metadata
python3 scripts/flag-lint.py \
&& npx ajv-cli validate -s flags/schema.json -d 'flags/registry.json' \
|| { echo "Flag validation failed"; exit 1; }
This feeds directly into the preventing flag sprawl workflow: once every flag has an expiry date, a nightly job can surface past-expiry flags automatically and route them to their owner.
Verification
Submit a PR that adds a flag with a bad key and confirm the CI job rejects it:
# Add a deliberately invalid key to test the CI gate
echo '{"newCheckoutFlow": {"state":"ENABLED","variants":{"on":true,"off":false},"defaultVariant":"off","metadata":{"owner":"payments-team","type":"release","created":"2026-06-20","expiry":"2026-08-20","state":"active"}}}' \
> /tmp/bad-flag-test.json
python3 scripts/flag-lint.py --registry /tmp/bad-flag-test.json
# Expected: [FAIL] INVALID KEY FORMAT: 'newCheckoutFlow'
# Exit code: 1
Then add a valid key and confirm it passes:
echo '{"checkout.payments.express-pay": {"state":"ENABLED","variants":{"on":true,"off":false},"defaultVariant":"off","metadata":{"owner":"payments-team","type":"release","created":"2026-06-20","expiry":"2026-08-20","state":"active"}}}' \
> /tmp/good-flag-test.json
python3 scripts/flag-lint.py --registry /tmp/good-flag-test.json
# Expected: All 1 flag keys passed lint.
# Exit code: 0
Gotchas & Edge Cases
- Multi-region or multi-tenant namespaces: if a namespace like
checkoutis deployed across multiple regions, do not encode the region in the key (e.g.checkout-eu.payments.express-pay). Region targeting belongs in the evaluation context, not the key. The key identifies the feature; the context routes evaluation. - SDK-generated keys from third-party tools: some SaaS flag providers auto-generate keys from display names, producing camelCase or space-separated strings. If you import flags from a vendor, run the lint as a post-import validation step and rename violations before committing them to your registry.
- Dot collision in JSON paths: in some templating or config systems, dotted keys are interpreted as nested objects rather than flat strings. If your provider stores flags in YAML or JSON where dots create hierarchy, you may need to quote keys (
"checkout.payments.express-pay") or configure the provider to treat dots literally. Test this with your specific provider before rolling out the schema.
Troubleshooting & FAQ
Our existing flags use underscores — do we need to rename them all at once?
No. Introduce a legacy_key field in the metadata for any flag that cannot be renamed in a single PR (because it appears in 30 services, for example). Keep the old key active, add the new key alongside it, migrate call sites service by service, then archive the old key. The lint can be configured to accept legacy_key-annotated entries as warnings rather than failures during the migration window.
Can the namespace segment match an existing DNS or Kubernetes namespace?
It can, but it does not have to. The flag namespace is a logical grouping for governance, not a runtime routing mechanism. If your Kubernetes namespace is checkout-prod and your flag namespace is checkout, that is fine — they serve different purposes. Do not add environment names (-prod, -staging) to the flag key itself; target environments via evaluation context attributes or separate provider environments.
What happens when a service is renamed or split?
Create new flags under the new service name immediately. For existing flags under the old name, add the new name as an alias in the registry metadata and migrate at the next deprecation cycle. Never bulk-rename live flags in production without a staged migration — a key change is a new flag from the SDK’s perspective, and the old variant state does not transfer automatically.