Skip to content

envdrift validate

Validate one or more .env files against a Pydantic Settings schema.

Synopsis

envdrift validate [OPTIONS] [env_files]... --schema SCHEMA

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 empty int/float/bool field fails like the real app (unless the schema sets env_ignore_empty=True, in which case the value counts as unset).
  • SettingsConfigDict(env_prefix="MYAPP_") is applied to plain field names, so api_key binds to MYAPP_API_KEY under the default case-insensitive matching. Explicit field aliases and validation aliases bypass the prefix, including every candidate in AliasChoices. 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=True requires 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,c fails for TAGS: list[str] while TAGS=["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 validation type 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 an extra="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); interior KEY=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 way dotenv_values does: a name defined earlier in the same file wins over os.environ (looked up case-sensitively, exactly like python-dotenv), and an unset name falls back to the default (or ""), so PORT=${OFFSET}234 is 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 # comment is user's data even though the value contains a stray quote. A # directly after = is value content (K=#FF0000 is #FF0000), while whitespace then # after = is an inline comment on an empty value (K= # c is ""), matching python-dotenv 1.2.4 and later.
  • Physical lines end at \n / \r\n / \r only; 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. validate also 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 or uv 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) or env_file_encoding='utf-8-sig'. With an older python-dotenv and the default encoding the key keeps an invisible U+FEFF prefix, a non-UTF-8 env_file_encoding decodes the BOM bytes into other characters (latin-1 gives ), 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 every validate warning, it does not affect the --ci exit 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.path is the Python import path
  • ClassName is 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.

envdrift validate .env.production -s config.settings:ProductionSettings --ci

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.

envdrift validate .env.production -s config.settings:ProductionSettings --fix

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.

envdrift validate .env.production -s config.settings:ProductionSettings -v

Examples

Basic Validation

envdrift validate .env.production --schema config.settings:ProductionSettings

Output when passing:

╭─────────────────────── envdrift validate ───────────────────────╮
│ Validating: .env.production                                     │
│ Schema: config.settings:ProductionSettings                      │
╰─────────────────────────────────────────────────────────────────╯

Validation PASSED

Validation with Errors

envdrift validate .env.production --schema config.settings:ProductionSettings

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

envdrift validate .env.production -s config.settings:ProductionSettings --fix

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

envdrift validate .env.development -s config.settings:Settings --no-check-encryption

Verbose Output

envdrift validate .env.production -s config.settings:ProductionSettings -v

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 .env defines two keys that differ only in case (e.g. both API_KEY and api_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.

See Also

  • diff - Compare .env files
  • encrypt - Check/perform encryption
  • init - Generate schema from .env