Feature flags with pydantic settings

A feature flag is just configuration, so it belongs in the same validated settings object as everything else — not in a loose os.environ.get scattered through the code. This page models flags as typed fields, extending Multi-Environment Settings Override.

The reason to treat flags as first-class settings rather than ad-hoc environment reads is that flags carry unusually high stakes for how humble they look. A flag decides whether an unfinished feature is visible, whether a risky code path runs, whether a migration is active — and it is toggled by people under time pressure during rollouts and incidents. A flag that is silently misread, defaults the wrong way, or is spelled inconsistently across the codebase is a production incident waiting for its moment. Modelling flags as typed boolean fields on a validated settings object removes every one of those failure modes at once: the value is parsed correctly, the default is safe, the name is validated, and the flag documents itself in the schema.

The two anti-patterns below are the ones that actually cause outages — reading a flag as a raw string and defaulting a flag to on — and the secure implementation that follows is the small amount of structure that makes both of them impossible rather than merely unlikely.

Problem 1: flags read as raw strings

# ANTI-PATTERN: the bool("false") trap, per flag, everywhere
if os.environ.get("FEATURE_NEW_CHECKOUT"):    # "false" is truthy -> always on
    use_new_checkout()

Reading flags as raw strings reintroduces the truthiness bug for every flag. The mechanism is Python’s own truthiness rule: os.environ.get("FEATURE_NEW_CHECKOUT") returns the string "false" when someone sets the flag to false, and every non-empty string is truthy, so if os.environ.get(...) runs the “on” branch even though the operator meant to turn the feature off. The result is a feature that is stuck on precisely because someone tried to turn it off — one of the most counter-intuitive and common flag bugs there is. And because the pattern is copied per flag, the bug is reintroduced everywhere a flag is read, so a codebase with twenty flags has twenty independent chances to ship it, and it takes only one to cause an incident.

The insidious part is that the code looks correct. if os.environ.get("FLAG"): reads like “if the flag is set”, and it works fine as long as the value is "true" or the variable is unset. It only betrays you for the one input an operator is most likely to type when disabling a feature — false — which is exactly when getting it wrong matters most. A typed bool field eliminates the bug by parsing the string with pydantic’s boolean rules, which correctly map "false", "0", "no", and "off" to False. This is not a matter of remembering to be careful — the bug is structurally impossible once the flag is a bool field, because there is no raw string for the truthiness rule to misinterpret; pydantic has already resolved the string to a real boolean before your code ever sees it. The discipline “always compare flags carefully” becomes the property “flags are booleans”, and properties do not get forgotten the way disciplines do.

The truthiness trap when a flag is read as a raw string Setting FEATURE_NEW_CHECKOUT=false yields the string "false", which is truthy in Python, so a raw os.environ.get check turns the feature on; a typed bool field parses it correctly to False. FLAG="false" a string if os.environ.get(...) non-empty → truthy flag: bool field pydantic parses "false" feature ON wrong — meant off feature OFF correct
The same "false" string turns the feature on through a raw read and off through a typed bool field.

Problem 2: flags defaulting to on

# ANTI-PATTERN: missing variable enables an unfinished feature
ENABLE_BETA = os.environ.get("ENABLE_BETA", "true")   # on unless explicitly disabled

A flag should be off unless explicitly enabled; defaulting to on ships unfinished work. The default is the flag’s fail-safe direction, and for a feature flag that direction is almost always off. A flag exists to gate something not yet ready for everyone — a new checkout flow, a beta dashboard, an async path still being validated — so the safe state when nobody has said otherwise is “not active”. Default it to on and you invert that: every environment that forgets to explicitly disable the flag ships the unfinished feature, and “forgets to disable” is the normal case, because operators set the variables they know they need and leave the rest unset.

The failure is quiet and asymmetric. A flag that defaults off and is accidentally left off means a finished feature stays dark until someone notices — annoying, but safe and easily fixed. A flag that defaults on and is accidentally left on means an unfinished feature is live in production, possibly for users, possibly touching data — an incident. Because the cost of the two mistakes is so lopsided, the default should always favour the safe one, which is off. Modelling the flag as bool = False bakes that safe default into the type, so a missing variable disables rather than enables, and the burden of memory falls on turning a feature on, never on remembering to keep it off.

There is a naming discipline that reinforces the safe default: phrase the flag so that True means “the new thing is on”. A flag named new_checkout that defaults to False reads correctly — off means the old behaviour, on means the new. A flag named disable_new_checkout inverts the polarity and invites confusion, because now the safe default is True and a double negative lurks in every check. Keep flags positively phrased and default them to False, so the safe state and the default line up and nobody has to reason through a negation while deciding whether a feature is live.

Default-off versus default-on when a variable is missing A missing flag variable with a default of False leaves an unfinished feature safely dark; the same missing variable with a default of True ships the unfinished feature to production. variable not set the normal case default False feature stays dark — safe default True unfinished feature ships choose the safe direction: bool = False
With the variable unset, a False default keeps the feature dark; a True default ships it.

Secure implementation

# config/flags.py
from pydantic_settings import BaseSettings, SettingsConfigDict

class FeatureFlags(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="FEATURE_", extra="forbid")
    new_checkout: bool = False      # FEATURE_NEW_CHECKOUT; off by default
    async_emails: bool = False      # FEATURE_ASYNC_EMAILS
    beta_dashboard: bool = False    # FEATURE_BETA_DASHBOARD

flags = FeatureFlags()              # "false"/"0"/"no" parsed correctly to False

if flags.new_checkout:             # typed bool, validated at startup
    use_new_checkout()

env_prefix="FEATURE_" groups the flags, every flag is a typed bool defaulting to False, and pydantic parses "false"/"0"/"no" correctly. A flag is never an unparsed string.

Three design choices in that small class do the work. The env_prefix="FEATURE_" means each field maps to a FEATURE_-prefixed variable — new_checkout reads FEATURE_NEW_CHECKOUT — which groups all flags under one obvious namespace in the environment and keeps them visually distinct from other configuration. The bool = False on every field gives each flag the safe default and the correct parsing in one stroke. And extra="forbid" combined with the prefix means a misspelled flag variable — FEATURE_NEW_CHEKOUT — is caught at startup as an unknown key rather than silently doing nothing, which matters because a typo’d flag name is otherwise indistinguishable from a flag left at its default — both leave the feature off, so nothing tells you the variable you set is being ignored. Reading a flag then becomes if flags.new_checkout:, a plain typed boolean check with no parsing to get wrong, no default juggling, and no truthiness trap.

Grouping flags on their own FeatureFlags model, separate from the main application settings, is a small organisational choice that pays off. It keeps the flag namespace tidy — everything under FEATURE_ in the environment and under flags.* in code — and it makes the complete set of flags discoverable in one place, which matters because flags proliferate and an undiscoverable flag is one nobody remembers to remove. You can still expose the flags through the same cached accessor pattern as the rest of your configuration, so get_flags() is injected or imported wherever a decision needs a flag, and tests can build a FeatureFlags instance directly with specific flags set to exercise both the on and off paths of a feature without standing up the whole application configuration.

Testing is in fact one of the strongest reasons to model flags this way. Because a flag is a field on a model, a test can construct FeatureFlags(new_checkout=True) and FeatureFlags(new_checkout=False) explicitly and assert that the feature behaves correctly in each state — the on path and the off path both covered, deterministically, with no dependence on ambient environment variables. That is far cleaner than a test that has to monkeypatch os.environ to exercise a raw-string flag, and it means the two states of every feature are as testable as any other branch in your code.

A FeatureFlags model maps prefixed variables to typed booleans With env_prefix FEATURE_, the variables FEATURE_NEW_CHECKOUT, FEATURE_ASYNC_EMAILS, and FEATURE_BETA_DASHBOARD map to typed bool fields defaulting to False on one FeatureFlags model. env vars (FEATURE_ prefix) FEATURE_NEW_CHECKOUT FEATURE_ASYNC_EMAILS FEATURE_BETA_DASHBOARD FeatureFlags env_prefix, forbid new_checkout: bool async_emails: bool beta_dashboard: bool all default False
The prefix maps each grouped variable to a typed, default-off boolean field.

Gotchas & version-specific behaviour

  • pydantic parses bool strings properly; never wrap a flag in bool().
  • Default every flag to False so a missing variable disables, not enables.
  • extra="forbid" with a prefix catches a typo’d flag name at startup.
  • For per-environment rollout, flip the variable via the environment overlay, not code.

The per-environment rollout point is where flags-as-settings pays off operationally. Because a flag is just a value on the same settings object as everything else, you enable it the same way you set any other value: flip the FEATURE_NEW_CHECKOUT variable in one environment’s overlay or injected variables, leave it off everywhere else. That gives you a clean canary workflow — ship the flagged code to production with the flag off, enable it in a staging or canary environment, watch, then enable it in production, all without touching code or redeploying. And because the flag defaults off, reverting is symmetric: unset the variable (or set it false) and the feature goes dark again, no rollback required. The flag’s lifecycle — introduced off, canaried on, rolled out, and eventually removed — is entirely a configuration and schema exercise, never a code-branch-per-environment one.

It is worth being clear about where this approach fits and where it does not. Settings-based flags are ideal for deploy-time toggles: values that are decided per environment and change on the cadence of a deploy or a configuration push — a feature gated for a canary, a code path being validated, a migration switch. They are not a replacement for a dynamic feature-flag service that targets individual users, runs percentage rollouts, or flips flags at runtime without a restart; those need a dedicated system that evaluates rules per request. The two coexist cleanly: use typed settings flags for the environment-level toggles that make up most flag usage, and reach for a runtime flag service only when you genuinely need per-user targeting or live changes. For the large majority of flags — “is this feature on in this environment?” — a typed boolean setting is simpler, safer, and needs no extra infrastructure.

Four rules for feature flags as settings Let pydantic parse bool strings and never wrap a flag in bool; default every flag to False; use extra=forbid with a prefix to catch typos; flip per environment via the overlay, not code. ParsingDefault off Typo-proofRollout let pydantic parse; never bool() a flag bool = False so a missing var disables extra="forbid" + prefix catches a bad name flip per environment via the overlay, not code
Parse with pydantic, default off, forbid typos, and roll out through configuration.

Production parity checklist

  • Flags are typed boolean fields on a settings object, not loose env reads.
  • Every flag defaults to False.
  • Flags share a prefix and extra="forbid".
  • Per-environment values come from injected variables, not code branches.
  • Removing a flag follows the schema evolution process.

The last item points at the part of a flag’s life that teams most often neglect: removal. A flag is meant to be temporary — once a feature is fully rolled out and stable, the flag and its branches are dead weight, and every abandoned flag is a small trap that a future operator might toggle by accident, or that hides which flags are actually still meaningful. Retiring a flag is a schema change, so it follows the same additive-then-subtractive discipline as any field removal: stop reading the flag in code and delete its branches, deploy that, and only then remove the field and stop setting the variable — otherwise extra="forbid" would turn a leftover FEATURE_* variable into a hard startup failure. Treating flag removal as a real step, tracked and scheduled, is what keeps the flag set from accreting into a confusing pile of half-remembered toggles.

Feature-flag production-parity checklist Flags are typed boolean fields not loose reads, every flag defaults False, flags share a prefix and extra=forbid, per-environment values come from injected variables, and removal follows schema evolution. Flags are typed boolean fields on a settings object, not loose env reads Every flag defaults to False so a missing variable disables it Flags share a prefix and extra="forbid" catches a typo'd name Per-environment values come from injected variables, not code branches Removing a flag follows the additive-then-subtractive schema process
Five checks that keep flags typed, safe by default, and cleanly retired.

Key takeaways

Model flags as defaulted boolean fields and they validate, document themselves, and never fall into the truthiness trap. A flag is configuration like any other, so it belongs on the same validated settings object — typed as bool, defaulted to False, grouped under a prefix, and protected by extra="forbid". That single move eliminates the two anti-patterns that cause real incidents: the truthiness trap of reading a flag as a raw string, and the danger of a flag that ships an unfinished feature because it defaulted to on.

The operational win is that a flag’s whole life becomes a configuration exercise. You introduce it off, enable it in one environment to canary, roll it wider by flipping variables, and eventually retire it through the schema-evolution process — never a code branch per environment, never a redeploy to toggle, never a rollback to disable. Flip them per environment through overrides — see Override Pydantic Settings per Environment.