envdrift validate¶
Validate one or more .env files against a Pydantic Settings schema.
Synopsis¶
Description¶
The validate command checks that your .env file matches your Pydantic Settings schema. It verifies:
- Required variables - All required fields in the schema exist in the .env file
- Type validation - Values are accepted by the field's type using real pydantic-settings semantics
- Extra variables - Warns about variables not defined in the schema (errors if
extra="forbid") - Encryption status - Optionally warns if sensitive fields are not encrypted
Type validation mirrors what the real app does at startup:
- Booleans accept every spelling Pydantic v2 accepts (
true/false,1/0,yes/no,on/off,t/f,y/n, any case). - A present-but-empty value (
PORT=) is validated as the empty string pydantic-settings actually passes through, so an emptyint/float/boolfield fails like the real app (unless the schema setsenv_ignore_empty=True, in which case the value counts as unset). SettingsConfigDict(env_prefix="MYAPP_")is applied to plain field names, soapi_keybinds toMYAPP_API_KEYunder the default case-insensitive matching. Explicit field aliases and validation aliases bypass the prefix, including every candidate inAliasChoices. If several choices are present, the first declared choice supplies the value; every choice is still recognized as a schema variable and, for sensitive fields, as sensitive.case_sensitive=Truerequires each binding's casing to match exactly.- Complex fields (
list,dict, nested models) are JSON-decoded first, exactly like the pydantic-settings env source:TAGS=a,b,cfails forTAGS: list[str]whileTAGS=["a","b","c"]passes. - Integer parsing is ASCII-only, matching Pydantic (fullwidth digits are rejected).
- Field and model validators run against the assembled settings values. A model-level
rejection appears as a
Model validationtype error instead of a false pass. - If custom validation raises an unexpected non-validation exception, validation stays crash-safe and reports only the exception type as a warning; the exception message is omitted because it may contain setting values.
- dotenvx's
DOTENV_PUBLIC_KEY*artifact is exempt from the extra-variable check and the sensitive-name warning, so encrypted files validate cleanly against anextra="forbid"schema.
The file itself is parsed with python-dotenv's quoting rules — the parser pydantic-settings uses — so quoted values (well-formed or malformed) are judged exactly as the real app loads them:
- A value opened with
"or'continues across physical lines until the matching close quote (multiline PEM certificates and keys parse as one value); interiorKEY=value-looking lines are part of the value, never separate variables. - Inside quoted values the python-dotenv escape sequences are decoded (
\n,\t,\",\\, ... in double quotes;\\and\'in single quotes). - Malformed quoted bindings are rejected exactly like python-dotenv: an unterminated quote, or non-comment content after the close quote, drops the whole binding (no truncated value, no phantom variables from interior lines). A validation warning identifies the binding's starting line so the omission is never silent.
${VAR}and${VAR:-default}references are expanded the waydotenv_valuesdoes: a name defined earlier in the same file wins overos.environ(looked up case-sensitively, exactly like python-dotenv), and an unset name falls back to the default (or""), soPORT=${OFFSET}234is judged as the value the app receives.- Unquoted values follow python-dotenv's comment rule: the whitespace right
after
=is consumed first, then the value is cut at the first whitespace-preceded#.MSG=user's data # commentisuser's dataeven though the value contains a stray quote. A#directly after=is value content (K=#FF0000is#FF0000), while whitespace then#after=is an inline comment on an empty value (K= # cis""), matching python-dotenv 1.2.4 and later. - Physical lines end at
\n/\r\n/\ronly; other Unicode line boundaries (U+2028, form feed, ...) are value content. - A leading UTF-8 BOM is stripped instead of becoming part of the first key,
exactly like python-dotenv 1.2.3 and later, so the verdict is about the key
you wrote.
validatealso warns, because whether the running app sees that key depends on the app's own environment, which envdrift cannot inspect (envdrift is often installed in an isolated venv, pipx oruv tool). A pydantic-settings app loads it under its real name only with python-dotenv 1.2.3 or later (pydantic-settings accepts 0.21.0 and later) orenv_file_encoding='utf-8-sig'. With an older python-dotenv and the default encoding the key keeps an invisibleU+FEFFprefix, a non-UTF-8env_file_encodingdecodes the BOM bytes into other characters (latin-1gives), and a shell sourcing the file does not set the key at all. Each way, a required field backed by it comes up missing at startup. Re-save the file as UTF-8 without a BOM. Like everyvalidatewarning, it does not affect the--ciexit code.
Quoted keys and bare KEY bindings without = also follow python-dotenv. Bare
bindings are omitted from validation because pydantic-settings filters their
None values before model validation. Other non-comment content that the lexer
cannot parse is ignored with a line-numbered warning; comments and blank lines
do not warn.
Multiple env files can be validated in one invocation (each gets its own report;
with --ci the exit code is 1 if any file fails). This keeps the command usable
as a pre-commit pass_filenames: true hook, where every matched staged file is
appended to a single command line.
Arguments¶
| Argument | Description | Default |
|---|---|---|
[env_files]... |
Path(s) to the .env file(s) to validate | .env |
Options¶
--schema, -s (required)¶
Dotted path to your Pydantic Settings class.
envdrift validate .env.production --schema config.settings:ProductionSettings
envdrift validate .env -s myapp.config:Settings
The format is module.path:ClassName where:
module.pathis the Python import pathClassNameis the Settings class name
--service-dir, -d¶
Directory to add to Python's sys.path for imports. Defaults to the current
directory, so a schema generated by envdrift init in your project root (e.g.
settings.py) imports without this flag. Set it for monorepos or when the schema
lives in a different directory.
# Schema in the project root (settings.py) — no --service-dir needed
envdrift validate .env -s settings:Settings
# Schema is in /app/backend/config/settings.py
envdrift validate .env -s config.settings:Settings -d /app/backend
# Monorepo structure
envdrift validate services/api/.env -s config:Settings -d services/api
--ci¶
CI mode: exit with code 1 if validation fails. Use this in CI/CD pipelines.
Without --ci, the command always exits with code 0 (unless there's a fatal error like missing file).
--check-encryption / --no-check-encryption¶
Control whether to check if sensitive variables are encrypted.
# Check encryption (default)
envdrift validate .env.production -s config.settings:ProductionSettings --check-encryption
# Skip encryption check
envdrift validate .env.production -s config.settings:ProductionSettings --no-check-encryption
When enabled, unencrypted sensitive fields are reported as warnings (not errors). Use envdrift encrypt --check for strict encryption enforcement.
Sensitive fields are matched by every validation alias when present, so aliased
fields (including all AliasChoices candidates) are not also reported as unmarked
sensitive.
--fix¶
Output a template for missing variables. Useful for quickly adding required fields.
Note: --fix only prints a template when validation fails; it is a no-op when validation passes.
Example output:
Fix template:
# Missing required variables:
# API key for external service
NEW_API_KEY="encrypted:YOUR_VALUE_HERE"
# Database connection string
DATABASE_URL=
--verbose, -v¶
Show additional details including missing optional variables with their defaults.
Examples¶
Basic Validation¶
Output when passing:
╭─────────────────────── envdrift validate ───────────────────────╮
│ Validating: .env.production │
│ Schema: config.settings:ProductionSettings │
╰─────────────────────────────────────────────────────────────────╯
Validation PASSED
Validation with Errors¶
Output when failing:
╭─────────────────────── envdrift validate ───────────────────────╮
│ Validating: .env.production │
│ Schema: config.settings:ProductionSettings │
╰─────────────────────────────────────────────────────────────────╯
Validation FAILED
MISSING REQUIRED VARIABLES:
* DATABASE_URL - PostgreSQL connection string
* API_KEY - Backend service API key
TYPE ERRORS:
* PORT: Expected integer, got 'not_a_number'
Summary: 3 error(s), 0 warning(s)
Run with --fix to generate template for missing variables.
CI/CD Pipeline¶
# GitHub Actions
- name: Validate environment
run: |
pip install envdrift
envdrift validate .env.production \
--schema config.settings:ProductionSettings \
--ci
Generate Fix Template¶
Validate Multiple Environments¶
# Development
envdrift validate .env.development -s config.settings:DevelopmentSettings --ci
# Staging
envdrift validate .env.staging -s config.settings:StagingSettings --ci
# Production
envdrift validate .env.production -s config.settings:ProductionSettings --ci
Skip Encryption Warnings¶
Verbose Output¶
Shows missing optional variables:
MISSING OPTIONAL VARIABLES (have defaults):
* DEBUG (default: False)
* LOG_LEVEL (default: INFO)
* WORKERS (default: 4)
Schema Requirements¶
Your Pydantic Settings class should be importable. If you have module-level code that instantiates settings, check for ENVDRIFT_SCHEMA_EXTRACTION:
import os
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
DATABASE_URL: str
DEBUG: bool = False
# Skip instantiation during schema extraction
if os.getenv("ENVDRIFT_SCHEMA_EXTRACTION"):
settings = None
else:
settings = Settings()
What Gets Validated¶
| Check | Error/Warning | Description |
|---|---|---|
| Missing required vars | Error | Fields without defaults must exist |
| Type mismatches | Error | Values must be accepted by pydantic for the type |
| Field/model validator rejection | Error | Custom Pydantic validators must accept the settings |
| Unexpected validator exception | Warning | Type is reported without echoing its potentially sensitive message |
| Empty values for non-str fields | Error | PORT= crashes a real int field at startup |
| Complex fields with invalid JSON | Error | list/dict/nested fields are JSON-decoded |
Extra vars (with extra="forbid") |
Error | Unknown variables not allowed (DOTENV_PUBLIC_KEY* exempt) |
Extra vars (with extra="ignore") |
Warning | Unknown variables allowed but noted |
| Unencrypted sensitive vars | Warning | Fields marked sensitive=True should be encrypted |
| Case-insensitive name collision | Warning | Two .env keys differ only in case |
| Unparsable non-comment content | Warning | Ignored content is reported by starting line number |
Note: Variable names are matched against schema fields case-insensitively, mirroring Pydantic Settings (
case_sensitive=False). So an UPPERCASE.env(e.g.API_KEY) satisfies a lowercase field (api_key). If a single.envdefines two keys that differ only in case (e.g. bothAPI_KEYandapi_key), they collapse to one field: the last occurrence wins (matching Pydantic) and a warning reports which value was kept and which was ignored, so the collision is never silent.