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, whileK= # ckeeps the whole# cas its value (a leading#has no whitespace before it inside the value). - 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,
and
validatewarns about it: pydantic-settings reads plain UTF-8, so the running app still sees the BOM-prefixed key unless the BOM is removed orenv_file_encoding='utf-8-sig'is set.
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_FILE... |
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.