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.

Annotated breakdown of a feature flag key The key checkout.payments.express-pay is split into three labelled segments: namespace (checkout), service (payments), and feature (express-pay), each with an explanation of its role. checkout . payments . express-pay namespace bounded context or product domain checkout · api · web · ops service owning microservice or team boundary payments · search · auth feature capability being gated kebab-case, descriptive express-pay · semantic-rerank
The key 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

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

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.