envdrift diff¶
Compare two .env files and show differences.
Synopsis¶
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.
--service-dir, -d¶
Directory to add to Python's sys.path for schema imports.
--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!
By default, values are shown but sensitive fields (when schema is provided) are masked.
--include-unchanged¶
Include variables that are identical in both files.
--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 "vsDATABASE_URL=foo), - both look like booleans (
true|false|yes|no|on|off|1|0, any case) and share the same truthiness (DEBUG=truevsDEBUG=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"]vsCORS_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=1vsPORT=01). - One side coerces and the other fails (it would crash the real app): that is
drift — e.g.
PORT=1vsPORT=trueunderPORT: intis 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¶
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¶
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¶
Sensitive fields (marked with json_schema_extra={"sensitive": True}) are labeled in output.
Show All Variables¶
Expose Values (Use with Caution)¶
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:
Onboarding New Team Members¶
Compare your .env with the template:
Detecting Configuration Drift¶
Monitor differences across environments:
for env in staging production; do
echo "=== Development vs $env ==="
envdrift diff .env.development .env.$env
done