Validate Enum and Log-Level Fields in Pydantic
A LOG_LEVEL of DEBGU or an APP_ENV of prd should stop the process at startup, not silently fall through to a default. Typing these fields as a Python Enum or a Literal turns a free-form string into a closed set that pydantic validates on construction. This builds on strict mode and type coercion and the pydantic-settings fundamentals.
The insight is that many configuration values are not really strings — they are choices from a small, fixed menu. An environment is one of dev, staging, prod; a log level is one of a handful of named severities; a storage backend is one of the three you support. Typing such a field as str throws that structure away and accepts any string at all, including the misspellings and casings that a menu would have rejected. Typing it as an Enum or Literal restores the structure: pydantic checks the incoming value against the allowed set at construction, so a typo becomes a startup ValidationError naming the field and listing the permitted values, instead of a silent fall-through that misbehaves later.
The payoff is two-sided. On the input side, a closed set catches bad configuration at the boundary — LOG_LEVEL=DEBGU fails immediately with a message that even shows the valid options. On the usage side, an enum gives your code real members to compare against (settings.app_env is AppEnv.prod) instead of fragile string equality, so the “is this production?” check cannot silently miss because someone wrote Production instead of prod. Both halves of that — validated input and safe comparison — come from the single decision to type the field as a set rather than a string.
Problem 1: accepting any string
# ANTI-PATTERN
log_level: str = "INFO" # "DEBGU" is accepted; logging silently misconfigures
A typo passes validation and only surfaces later as missing log output. This is a nasty failure precisely because it is so quiet. A str field accepts "DEBGU" without complaint; the logging library, handed a level name it does not recognise, falls back to a default or ignores the setting; and the first sign of trouble is an incident where the debug logs you went looking for simply are not there. Nothing crashed, nothing warned, and the root cause — a single transposed letter in an environment variable — is invisible unless you happen to inspect the resolved level. The cost of the missing structure is paid at the worst possible time, during a debugging session that the misconfiguration itself has sabotaged.
Log level is the canonical example, but the same shape recurs everywhere a value is really a choice: a CACHE_BACKEND of redis versus memcached, a PAYMENT_PROVIDER of stripe versus adyen, a REGION drawn from a fixed, known list of deployment regions. In every case a plain str field is an open door that accepts the near-misses a closed set would reject, and in every one of them the fix is the same — declare the allowed values as a type so pydantic can enforce them at startup.
Problem 2: branching on raw strings
# ANTI-PATTERN
if settings.app_env == "prod": # "production" and "PROD" silently take the else branch
enable_tracing()
Comparing free-form strings scatters environment knowledge and breaks on any casing difference. The problem compounds because these comparisons multiply: == "prod" appears in the tracing setup, again in the error-reporting config, again in a feature gate, and each copy is an independent chance to write "production" or "Prod" and silently take the wrong branch. There is no single source of truth for “what are the valid environments?”, so the knowledge is smeared across the codebase, and a fourth environment added later means hunting down every scattered string comparison in the codebase to update by hand.
An enum collapses all of that. Because AppEnv defines the allowed environments once, every comparison is settings.app_env is AppEnv.prod against a real member, and a typo like AppEnv.prd is a AttributeError your editor and type checker catch before the code ever runs — not a string that silently fails to match. The set of valid environments lives in one place, adding one is a single edit, and the “is this production?” question has exactly one correct spelling. The difference is between configuration knowledge scattered as fragile string literals and configuration knowledge centralised in a type the tools understand.
Secure implementation
Restrict the field to a closed set with a str Enum (reusable) or a Literal (lightweight).
# config.py
from enum import Enum
from typing import Literal
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class AppEnv(str, Enum):
dev = "dev"
staging = "staging"
prod = "prod"
class Settings(BaseSettings):
model_config = SettingsConfigDict(extra="forbid")
app_env: AppEnv = AppEnv.dev
# a Literal is enough when the values need no behaviour:
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
@field_validator("log_level", mode="before")
@classmethod
def upper(cls, v: str) -> str:
return v.upper() if isinstance(v, str) else v # accept "info" -> "INFO"
settings = Settings() # raises ValidationError naming log_level/app_env if out of set
Now settings.app_env is AppEnv.prod is a safe, typo-proof comparison, and an invalid LOG_LEVEL fails before logging is configured.
The example shows both tools deliberately, because the choice between them is the main decision here. A str-based Enum is the heavier, more capable option: it is a real type you can import and reference, its members can carry behaviour or be grouped in match statements, and it is the right choice when the same set of values appears in more than one place or when the values mean something your code acts on. A Literal is the lightweight option: it restricts a field to a handful of strings with zero ceremony, no separate class to define, and is ideal when the value is just a constrained string you validate but never need to reference as a named member. A useful rule of thumb is that app_env, which your code branches on, wants an Enum, while log_level, which you mostly pass straight to the logging library, is fine as a Literal.
Subclassing str, Enum (rather than plain Enum) is what makes the enum behave well at the edges. Because the member is a string, it serializes as its string value in model_dump() and JSON, compares equal to that string, and can be passed to any function expecting a plain str — so the enum adds validation and safe comparison without forcing every downstream consumer to know about the enum type. The mode="before" validator that upper-cases the input is the other quality-of-life piece: environments are inconsistent about casing, and normalising "info" to "INFO" before validation means an operator who types the level in lower case gets the value they intended rather than a rejection, while a genuine typo like "DEBGU" still fails.
Gotchas & version-specific behaviour
- Subclass
str, Enumso the value serializes as its string inmodel_dump()and JSON. Literalvalidation is case-sensitive; add amode="before"validator to normalise casing.- pydantic v2 accepts either the enum member or its value from the environment; it rejects anything else.
- Enums give you exhaustive
matchstatements and IDE autocomplete that raw strings do not. - Keep the default inside the allowed set, or the model cannot construct without the variable set.
The default-inside-the-set point is a subtle one that causes confusing failures. If you type a field as Literal["DEBUG", "INFO", ...] but give it a default of "info" (lower case), the default itself is not a member of the allowed set, so the model cannot construct when the variable is unset — the field’s own default fails its own validation. The fix is to make the default one of the exact allowed values, and to let the normalising validator handle operator-supplied casing. It is the kind of error that is obvious once seen and baffling until then, because the failure appears even in an environment where nobody set the variable at all.
The case-sensitivity behaviour is worth stating plainly: both Enum value matching and Literal matching are case-sensitive by default, so "prod" and "PROD" are different values as far as validation is concerned. This is a feature, not a bug — it keeps the allowed set precise — but it means you must decide, per field, whether to accept casing variation. If the environment is disciplined, define the enum in the exact casing it supplies and require an exact match. If it is not, add the mode="before" normaliser so the model is forgiving about case while still strict about the actual set of values. Either is defensible; what is not defensible is a bare str that accepts every casing and every typo.
Production parity checklist
- Type every “one of a fixed set” field as an
EnumorLiteral, never a barestr. - Normalise casing in a
mode="before"validator if the environment is inconsistent. - Compare against enum members, not string literals, in application code.
- Add a CI test that constructs the model with an invalid value and asserts it raises.
- Keep the default value within the allowed set.
Frequently asked questions
Should I use an Enum or a Literal for a fixed set of config values?
Use a str-based Enum when the values have behaviour or are reused across the codebase, and a Literal when you just need to restrict a field to a handful of strings. Both make pydantic reject any value outside the set at startup. The Enum gives you an importable type, named members to compare against, and exhaustive match support; the Literal gives you the same input validation with no class to define. A good default is a Literal for values you only validate and pass through, and an Enum for values your code branches on.
How do I validate LOG_LEVEL from an environment variable?
Type the field as a Literal of the allowed names or a str Enum. pydantic coerces the incoming string and raises a ValidationError naming the field if it is not one of the allowed values, before logging is configured. Because the check happens at model construction — which you do at startup — an invalid level stops the process immediately, with an error that even lists the permitted names, rather than degrading logging silently at runtime. Add a mode="before" validator to accept lower-case input if your operators are inconsistent about casing.
Why does my enum field reject a lowercase value?
Enum matching is case-sensitive on the member value. Normalise with a field_validator that upper-cases the input, or define the enum values in the exact casing your environment supplies. Case-sensitivity is deliberate — it keeps the allowed set precise — but it means an operator who types debug instead of DEBUG gets a rejection unless you have normalised for it. A mode="before" validator that upper-cases (or lower-cases) the raw string before validation is the standard fix, keeping the model forgiving about case while still strict about which values exist.
Key takeaways
Model fixed-set configuration as an Enum or Literal so an invalid LOG_LEVEL or environment name fails at startup, and compare against enum members instead of raw strings in your code. The whole idea is to stop treating a menu choice as a free-form string: once the allowed values are a type, pydantic enforces them at the boundary and your code compares against real members, closing both the “typo accepted” and the “comparison silently missed” failure modes at once.
The practical rules are few. Use a str, Enum when the values are referenced or carry behaviour, a Literal when you just need to constrain a string. Subclass str so the enum serializes and compares cleanly. Add a mode="before" validator if the environment’s casing is inconsistent, and keep the field’s default inside the allowed set so the model can construct with the variable unset. Compare with is against enum members in application code, never string literals. And add a CI test that constructs the model with a deliberately invalid value and asserts it raises, so the closed set is a proven guarantee rather than an assumption. Do that, and a misspelled environment or log level becomes a loud, early error instead of a silent, late surprise.