Skip to content

envdrift diff

Compare two .env files and show differences.

Synopsis

envdrift diff ENV1 ENV2 [OPTIONS]

Description

The diff command compares two .env files and shows:

  • Added variables - Present in ENV2 but not ENV1
  • Removed variables - Present in ENV1 but not ENV2
  • Changed variables - Different values between files
  • Unchanged variables - Same in both (with --include-unchanged)

This is useful for:

  • Reviewing differences between development and production
  • Auditing environment changes before deployment
  • Detecting drift between team members' environments

Arguments

Argument Description
ENV1 Path to first .env file (baseline)
ENV2 Path to second .env file (comparison)

Options

--schema, -s

Schema for sensitive field detection. When provided, sensitive fields are masked in output.

envdrift diff .env.dev .env.prod --schema config.settings:Settings

--service-dir, -d

Directory to add to Python's sys.path for schema imports.

envdrift diff .env.dev .env.prod -s config.settings:Settings -d /app/backend

--format, -f

Output format: table (default) or json. The value is case-insensitive (JSON is accepted); any other value exits with code 1 instead of silently falling back to the table view. In json mode, stdout is always pure JSON — diagnostics such as a failed --schema load are routed to stderr, and errors on the failure path are emitted as a clean {"error": "…"} object (no ANSI, no Rich prose, even under FORCE_COLOR=1) so a --format json > drift.json capture stays parseable.

Bad inputs fail cleanly with a non-zero exit, never a traceback: a directory where a file is expected reports Not a file: <path>, and a binary / non-UTF-8 file reports Could not read <path> as UTF-8 text.

# Human-readable table (default)
envdrift diff .env.dev .env.prod --format table

# Machine-readable JSON
envdrift diff .env.dev .env.prod --format json

--show-values

Show actual values instead of masking them. Use with caution - this may expose secrets!

envdrift diff .env.dev .env.prod --show-values

By default, values are shown but sensitive fields (when schema is provided) are masked.

--include-unchanged

Include variables that are identical in both files.

envdrift diff .env.dev .env.prod --include-unchanged

--exit-on-drift, --ci

Exit with code 1 after rendering the normal table or JSON output when drift is detected. Both option names are aliases.

# Suitable for a CI gate: exits 1 for added, removed, or changed variables
envdrift diff .env.dev .env.prod --exit-on-drift

# Short CI-oriented alias with machine-readable output
envdrift diff .env.dev .env.prod --format json --ci

The flag is opt-in. Without it, detected drift remains a successful comparison and exits 0, preserving the command's existing scripting behavior. Identical files exit 0 with or without the flag.

--normalize, --strict

Normalize values before comparing so trivially-equivalent strings don't show up as drift. Enabled by default; pass --strict to fall back to raw string compare.

When --normalize is in effect, two values are considered equal if:

  • they match after stripping leading/trailing whitespace (DATABASE_URL="foo " vs DATABASE_URL=foo),
  • both look like booleans (true|false|yes|no|on|off|1|0, any case) and share the same truthiness (DEBUG=true vs DEBUG=True), or
  • both look like JSON lists/objects and parse to the same structure, regardless of single- vs double-quote style (CORS_ORIGINS=["http://x"] vs CORS_ORIGINS=['http://x']).

When --schema is also passed, each value is coerced through the matching Pydantic field type (bool, int, list[str], Literal[...], etc.) using the same pydantic-settings semantics validate uses, so the two commands always agree. Complex types (list/dict/nested models) are JSON-decoded first, like the real env source. Schema coercion is definitive in exactly two cases:

  • Both sides coerce to the same value: equal (PORT=1 vs PORT=01).
  • One side coerces and the other fails (it would crash the real app): that is drift — e.g. PORT=1 vs PORT=true under PORT: int is CHANGED; the bool-truthiness rule above never overrules a non-bool schema type.

Everything else — variables not in the schema, values that coerce on both sides but to different values, or values failing coercion on both sides — falls back to the universal rules above (minus the bool rule when a typed field failed coercion on both sides). Note the wider bool spellings Pydantic accepts (t/f/y/n, ...) only compare equal through this schema path; the universal bool rule recognizes just true|false|yes|no|on|off|1|0.

# Default — normalization on
envdrift diff .env.dev .env.prod

# Disable normalization for strict raw-string compare
envdrift diff .env.dev .env.prod --strict

# Schema-aware coercion plus normalization
envdrift diff .env.dev .env.prod --schema config.settings:Settings

Displayed values are always the original ones from the file — normalization only affects the changed / unchanged classification, never what you see in the table or JSON output.

Examples

Basic Comparison

envdrift diff .env.development .env.production

Output:

╭────────────────────── envdrift diff ──────────────────────╮
│ Comparing: .env.development vs .env.production            │
╰───────────────────────────────────────────────────────────╯

┏━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┓
┃ Variable        ┃ .env.development┃ .env.production ┃ Status   ┃
┡━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━┩
│ DEBUG           │ true            │ false           │ changed  │
│ LOG_LEVEL       │ DEBUG           │ WARNING         │ changed  │
│ SENTRY_DSN      │ (missing)       │ https://...     │ added    │
│ DEV_ONLY_VAR    │ testing         │ (missing)       │ removed  │
└─────────────────┴─────────────────┴─────────────────┴──────────┘

Summary: 2 changed, 1 added, 1 removed

Drift detected between environments

JSON Output for CI/CD

envdrift diff .env.dev .env.prod --format json

Output:

{
  "env1": ".env.dev",
  "env2": ".env.prod",
  "summary": {
    "added": 1,
    "removed": 1,
    "changed": 2,
    "has_drift": true
  },
  "differences": [
    {
      "name": "DEBUG",
      "type": "changed",
      "value_env1": "true",
      "value_env2": "false",
      "sensitive": false
    },
    {
      "name": "SENTRY_DSN",
      "type": "added",
      "value_env1": null,
      "value_env2": "https://...",
      "sensitive": false
    }
  ]
}

With Schema for Sensitive Detection

envdrift diff .env.dev .env.prod --schema config.settings:Settings

Sensitive fields (marked with json_schema_extra={"sensitive": True}) are labeled in output.

Show All Variables

envdrift diff .env.dev .env.prod --include-unchanged

Expose Values (Use with Caution)

envdrift diff .env.dev .env.prod --show-values

CI/CD Drift Detection

To fail a CI job when drift exists, enable the opt-in exit gate:

- name: Gate on environment drift
  run: envdrift diff .env.development .env.production --exit-on-drift

If a later step needs to parse and comment on the report, the default exit-0 behavior remains available:

# GitHub Actions - Comment on PR with drift report
- name: Check drift
  id: drift
  run: |
    envdrift diff .env.development .env.production --format json > drift.json

- name: Comment on PR
  uses: actions/github-script@v7
  with:
    script: |
      const fs = require('fs');
      const drift = JSON.parse(fs.readFileSync('drift.json', 'utf8'));

      if (drift.summary.has_drift) {
        github.rest.issues.createComment({
          issue_number: context.issue.number,
          owner: context.repo.owner,
          repo: context.repo.repo,
          body: `## Environment Drift Detected\n\n` +
                `- Added: ${drift.summary.added}\n` +
                `- Removed: ${drift.summary.removed}\n` +
                `- Changed: ${drift.summary.changed}`
        });
      }

Exit Codes

Code Meaning
0 Comparison completed; this remains the default even when drift exists
0 --exit-on-drift / --ci: files match
1 --exit-on-drift / --ci: drift was detected after output
1 Invalid format or unreadable, missing, or invalid input

Diff Types

Type Description
added Variable exists in ENV2 but not ENV1
removed Variable exists in ENV1 but not ENV2
changed Variable exists in both but with different values
unchanged Variable is identical in both files

Use Cases

Pre-Deployment Review

Before deploying, compare staging and production:

envdrift diff .env.staging .env.production

Onboarding New Team Members

Compare your .env with the template:

envdrift diff .env.example .env

Detecting Configuration Drift

Monitor differences across environments:

for env in staging production; do
  echo "=== Development vs $env ==="
  envdrift diff .env.development .env.$env
done

See Also