Practical infra coding

Check a .env file against .env.example

easyCLI tools

Problem statement

A container keeps failing to start because someone forgot to set a variable. Write check_env.py EXAMPLE ENV [--strict] [--optional K1,K2] that compares a real env file with the committed template and reports:

  • ERROR a key from the template that is missing from ENV.
  • ERROR a key that is present but empty, unless it is listed in --optional.
  • WARN a key in ENV that the template does not know (an ERROR with --strict).
  • WARN a key set twice (the later value wins) and any line that cannot be parsed.

Parsing rules: skip blank lines and # comments, allow an export prefix, allow spaces around =, and strip one pair of matching quotes around the value. Keys must be valid identifiers. Print the file warnings first (in file order), then the key checks in alphabetical order, then either N error(s) or env ok. Exit 1 on any error, 2 if a file cannot be read, otherwise 0. Never print the values themselves: they are secrets.

.env.example

Bash
# Required settings for the api service
DATABASE_URL=postgres://user:pass@localhost:5432/app
REDIS_URL=redis://localhost:6379/0
LOG_LEVEL=info
SENTRY_DSN=
PORT=8080

.env

TEXT
export DATABASE_URL="postgres://api:s3cret@db.internal:5432/api"
REDIS_URL=
LOG_LEVEL=debug
LOG_LEVEL=warn
PORT = 9090
FEATURE_X=on
this line is garbage

Examples

Example 1

Input: python check_env.py .env.example .env --optional SENTRY_DSN; echo "exit: $?"

Output: WARN .env:4: LOG_LEVEL set again, later value wins WARN .env:7: cannot parse line ERROR REDIS_URL is empty ERROR SENTRY_DSN is missing WARN FEATURE_X is not in .env.example 2 error(s) exit: 1

Explanation: export and the quotes around DATABASE_URL are handled, and PORT = 9090 parses despite the spaces. SENTRY_DSN may be empty but must still be present.

Example 2

Input: python check_env.py .env.example nope.env; echo "exit: $?"

Output: check_env.py: nope.env: No such file or directory exit: 2

Hints

Approach

Optimal

Parsing. parse_env reads line by line with enumerate(f, 1) for line numbers. After skipping blanks and comments and removing an export prefix, partition("=") splits on the first = only; split("=") would break a value like postgres://...?sslmode=require. A line with no = or a key that is not a valid identifier is reported with its line number and skipped instead of crashing the tool. One pair of matching quotes is stripped. A repeated key is a warning, because the later value silently overriding the earlier one is a classic source of confusion.

Comparing. Keys are compared with plain dict and set operations: every template key must exist, must be non-empty unless listed as optional, and every extra key is flagged. Sorting the keys makes the output stable, which matters when the report is pasted into a ticket or diffed.

Secrets. Messages mention keys and line numbers, never values. An env file usually holds passwords and tokens, and CI logs are widely readable.

Exit codes. 2 when a file cannot be read (the check could not run), 1 when the check ran and found errors, 0 otherwise. Warnings alone do not fail, unless --strict turns unknown keys into errors.

n is the number of keys in both files.

ComplexityTime O(n log n)Space O(n)
Python
import argparse
import sys
def parse_env(path):
# Returns (values, problems). values: key -> value (last one wins).
values, problems = {}, []
with open(path, encoding="utf-8") as f:
for lineno, raw in enumerate(f, 1):
line = raw.strip()
if not line or line.startswith("#"):
continue
if line.startswith("export "):
line = line[len("export "):]
key, sep, value = line.partition("=")
key, value = key.strip(), value.strip()
if not sep or not key.isidentifier():
problems.append(("WARN", f"{path}:{lineno}: cannot parse line"))
continue
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
value = value[1:-1]
if key in values:
problems.append(("WARN", f"{path}:{lineno}: {key} set again, later value wins"))
values[key] = value
return values, problems
def main(argv=None):
p = argparse.ArgumentParser(prog="check_env.py")
p.add_argument("example", help="template listing the expected keys, e.g. .env.example")
p.add_argument("env", help="the real env file to check")
p.add_argument("--strict", action="store_true", help="unknown keys are errors")
p.add_argument("--optional", default="", help="comma-separated keys that may be empty")
args = p.parse_args(argv)
optional = {k for k in args.optional.split(",") if k}
try:
expected, problems = parse_env(args.example)
actual, more = parse_env(args.env)
except OSError as e:
print(f"check_env.py: {e.filename}: {e.strerror}", file=sys.stderr)
return 2
problems += more
for key in sorted(expected):
if key not in actual:
problems.append(("ERROR", f"{key} is missing"))
elif actual[key] == "" and key not in optional:
problems.append(("ERROR", f"{key} is empty"))
for key in sorted(set(actual) - set(expected)):
problems.append(("ERROR" if args.strict else "WARN", f"{key} is not in {args.example}"))
for level, msg in problems:
print(f"{level:<5} {msg}")
errors = sum(1 for level, _ in problems if level == "ERROR")
print(f"{errors} error(s)" if errors else "env ok")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())

Follow-up questions

  • Check the live environment (os.environ) of a running container instead of a file.
  • Mark required keys in the template with a comment such as # required instead of a command-line flag.
  • Validate formats too: PORT must be a number, *_URL must parse as a URL.

Frequently asked questions

Missing or empty environment variables are one of the most common reasons a deploy starts but the service then crashes or misbehaves. A pre-deploy check that compares the real env with the template turns that runtime failure into a clear message in the pipeline.

REDIS_URL= looks set, so code that checks if 'REDIS_URL' in os.environ passes, and the failure happens later when the client tries to connect to an empty address. Treating empty as missing, with an explicit opt-out for keys that really may be empty, catches this early.

No. Different loaders disagree on multi-line values, escape sequences and ${VAR} expansion. The rules here cover the common subset; if the service uses a specific loader, the checker should follow that loader's rules or reuse its parser.