I Wrote a .env Linter from Scratch — Here Are the 9 Rules That Actually Matter
A stdlib-only Python CLI that catches the .env mistakes your runtime won't tell you about until production.
Environment files are deceptively simple. Key equals value, one per line, maybe some quotes. But that simplicity hides a class of bugs that only surface at runtime: a duplicated key silently overwriting another, a ${VAR} reference pointing at nothing, a space in an unquoted value truncating your connection string. I've hit all of these, and I got tired of debugging them one crash at a time.
So I wrote dotenv-lint — a strict linter for .env files that checks for the nine issues I've seen cause real problems. No dependencies, no configuration files, just point it at your .env and get a verdict.
📦 GitHub: https://github.com/sen-ltd/dotenv-lint
Why lint a .env file?
Most .env parsers are forgiving by design. They'll silently pick the last value when a key appears twice. They'll accept TIMEOUT=30 seconds and hand you the string "30 seconds" when you expected an integer. They won't tell you that the ${API_HOST} in your URL template doesn't resolve to anything defined in the same file.
These aren't hypothetical. In a Node.js project, I once spent an hour tracking down why the staging database was getting production traffic. The .env had DB_HOST defined twice — once at the top with the staging URL, once buried 40 lines down with the production URL. The dotenv library silently took the last one.
A linter catches this before you deploy.
The architecture
The tool is split into three modules that mirror how you'd think about the problem:
src/
├── parser.py # Parse .env → list of Entry objects
├── rules.py # Each rule: entries → list of Diagnostic
├── usage.py # Scan source files for env var references
└── cli.py # Wire it all together
The parser
The parser is deliberately not a dotenv loader. It doesn't evaluate anything — it preserves the raw structure so rules can reason about quoting, whitespace, and syntax. Each line becomes an Entry dataclass:
@dataclass
class Entry:
line_number: int
raw: str
key: Optional[str] = None
value: Optional[str] = None
quote_char: Optional[str] = None # None, '"', "'"
is_comment: bool = False
is_blank: bool = False
has_equals: bool = False
The key design decision: keep quote_char separate from value. A rule that checks for inconsistent quoting needs to know how a value was quoted, not just what the value is. And a rule that checks for unquoted spaces needs to know the value wasn't quoted at all.
The parser handles the common variations: export prefixes, inline comments (# like this), single and double quotes, and lines that look like keys but lack an = sign.
The rules
Each rule is a plain function with the signature list[Entry] -> list[Diagnostic]. No classes, no registration, no plugin system. Just functions in a list:
ALL_RULES: list[RuleFunc] = [
duplicate_keys,
missing_equals,
unquoted_spaces,
undefined_references,
inconsistent_quoting,
trailing_whitespace,
non_ascii_keys,
empty_value,
]
Adding a new rule means writing one function and appending it to that list. Let me walk through each.
The nine rules
1. duplicate-key (error)
DB_HOST="localhost"
# ... 40 lines later ...
DB_HOST="127.0.0.1"
Most dotenv parsers take the last value. This is almost never intentional — it's someone adding a key without checking if it already exists. The rule tracks first-seen line numbers so the diagnostic message tells you where the original definition was.
2. missing-equals (error)
ORPHAN_VAR
A line that looks like a key name but has no =. This usually means someone started typing a variable and forgot to finish, or copy-pasted a key name from documentation without the value. The parser distinguishes this from unparseable lines by checking if the text matches the pattern of a valid key name.
3. unquoted-spaces (warning)
API_TIMEOUT=30 seconds
Without quotes, some parsers will give you "30 seconds", others will give you "30". It depends on how they handle inline comments and whitespace. Quoting removes the ambiguity. This is a warning rather than an error because the behavior is merely unpredictable, not always wrong.
4. undefined-ref (error)
API_URL=https://${API_HOST}/v1/data
# API_HOST is not defined anywhere in this file
Variable interpolation with ${VAR} is supported by many dotenv implementations, but the referenced variable must actually exist. This rule collects all defined keys, then scans all values for ${...} patterns and reports any references that don't resolve. It's an error because the resulting value will contain a literal ${API_HOST} string or be empty, neither of which is what you wanted.
5. inconsistent-quoting (warning)
DB_HOST="localhost"
DB_PORT=5432
DB_NAME="myapp"
DB_PASS="secret"
When most values in a file use double quotes but a few don't, the unquoted ones are probably oversights. The rule finds the majority quoting style and flags the minority. This helps enforce a consistent house style without requiring a config file — the file's own conventions are the spec.
The implementation counts occurrences of each style (double-quoted, single-quoted, unquoted) and flags whichever styles are in the minority:
majority_style = max(styles, key=lambda s: len(styles[s]))
for style, lines in styles.items():
if style == majority_style:
continue
# Flag these lines
6. trailing-whitespace (warning)
CACHE_TTL=3600··· # (trailing spaces)
Invisible characters at the end of lines can sneak into values, especially with unquoted assignments. Some editors strip trailing whitespace automatically; others don't. The rule flags any non-blank line where raw != raw.rstrip().
7. non-ascii-key (error)
データベース=postgres
Environment variable names should use [A-Za-z_][A-Za-z0-9_]*. While some systems might accept Unicode key names, portability across shells, Docker, and CI systems is not guaranteed. This is an error because it will cause problems somewhere in the deployment pipeline.
8. empty-value (warning)
DB_PASS=
An empty value without explicit quoting is ambiguous. Did you mean to set it to an empty string, or did you forget to fill it in? Using DB_PASS="" makes the intent clear. The rule only fires when there are no quotes — DB_PASS="" passes cleanly.
9. unused-key (warning, opt-in)
dotenv-lint .env --check-usage src/
This one cross-references your .env keys against actual source code. The usage scanner looks for common patterns across multiple languages:
- Python:
os.getenv("VAR"),os.environ["VAR"] - Node.js:
process.env.VAR,process.env["VAR"] - Ruby:
ENV["VAR"],ENV.fetch("VAR") - Rust:
std::env::var("VAR") - Java:
System.getenv("VAR") - Generic:
env("VAR"),getenv("VAR") - Shell/Docker:
${VAR}
It automatically skips node_modules, .git, __pycache__, and other common non-source directories. If a key is defined in .env but never referenced in any scanned file, it gets flagged. This is opt-in because not every project keeps its source code next to its .env file.
Exit codes
The exit code tells you the severity at a glance:
| Code | Meaning |
|---|---|
| 0 | No issues found |
| 1 | Warnings only |
| 2 | One or more errors |
This makes it trivial to integrate into CI:
# GitHub Actions
- run: python main.py .env
# Fails the build on errors, passes on warnings
continue-on-error: false
Or with a stricter policy where even warnings fail:
python main.py .env
status=$?
if [ $status -ne 0 ]; then
echo ".env lint failed"
exit 1
fi
Testing
The test suite has 38 tests covering every rule individually plus CLI integration. Each rule test creates a minimal .env string, parses it, runs the specific rule function, and asserts on the diagnostics:
def test_duplicate_keys():
entries = parse_env("FOO=1\nBAR=2\nFOO=3")
diags = duplicate_keys(entries)
assert len(diags) == 1
assert diags[0].rule == "duplicate-key"
assert diags[0].severity == Severity.ERROR
assert diags[0].line == 3
The usage tests use pytest's tmp_path fixture to create real source files and verify the scanner finds the right patterns:
def test_scan_python_getenv(tmp_path):
src = tmp_path / "app.py"
src.write_text('import os\ndb = os.getenv("DATABASE_URL")\n')
result = scan_source_dir(tmp_path)
assert "DATABASE_URL" in result.used_vars
The CLI integration tests verify the full pipeline — parse, lint, exit code:
def test_errors_exit_2(tmp_path):
env = tmp_path / ".env"
env.write_text("A=1\nA=2\n")
code = main([str(env)])
assert code == 2
Docker
The Dockerfile uses a multi-stage build to keep the image small:
FROMpython:3.12-alpineASbuilder
WORKDIR /app
COPY src/ src/
COPY main.py .
RUN python -m compileall src/ main.py
FROM alpine:3.19
RUN apk add --no-cache python3 && \
adduser -D -h /app linter
WORKDIR /app
COPY --from=builder /app/ .
USER linter
ENTRYPOINT ["python3", "main.py"]
The builder stage compiles .pyc files for faster startup. The runtime stage uses plain alpine:3.19 with only the Python interpreter — no pip, no dev headers. The linter user ensures it doesn't run as root.
docker build -t dotenv-lint .
docker run --rm -v "$PWD/.env:/app/.env:ro" dotenv-lint .env
Design decisions worth noting
Why dataclasses instead of dicts
The parser returns Entry dataclasses, not dictionaries. This matters because rules need to ask questions like "does this entry have an equals sign?" and "what quote character was used?" With a dict, you'd be writing entry.get("quote_char") everywhere and hoping the key exists. With a dataclass, you get autocompletion, type hints, and a clear contract between the parser and the rules.
The Diagnostic type is similarly a dataclass rather than a tuple or dict. When you're collecting diagnostics from nine different rules and sorting them by line number, having .line, .severity, and .rule as named attributes makes the aggregation code readable:
errors = [d for d in diags if d.severity == Severity.ERROR]
has_errors = any(d.severity == Severity.ERROR for d in diags)
diags.sort(key=lambda d: d.line)
Why severity matters for exit codes
Splitting diagnostics into errors and warnings isn't just cosmetic — it drives the exit code. A duplicate key is an error because it will cause wrong behavior. Trailing whitespace is a warning because it might cause issues depending on your parser. This distinction lets CI pipelines make proportionate decisions: fail on errors, optionally fail on warnings.
Why the usage scanner is opt-in
The --check-usage flag requires a directory argument rather than scanning the current directory by default. This is intentional. Many projects have their .env file in the project root but their source code in src/, app/, or lib/. Guessing the right directory would produce false positives. Making the user specify it means the results are trustworthy.
The scanner also needs to be fast enough that you'd actually use it. Skipping node_modules and similar directories isn't just about correctness — a node_modules tree can contain tens of thousands of files. Without the skip list, scanning would take seconds instead of milliseconds.
What I didn't build
No config file, no .dotenvlintrc, no rule severity overrides, no inline suppression comments. These are all features that a mature linter needs, but they're scope creep for a tool whose value proposition is "point it at a file and get an answer." If you need to suppress a rule, fix the issue or remove the line. That's the point.
I also didn't build auto-fixing. A linter that modifies your .env file is one --fix flag away from silently deleting a key you meant to keep. Read the output, decide what to do, edit the file yourself.
No .env.example comparison either. Some teams maintain an .env.example as a template, and checking that every key in the example has a corresponding value in .env would be useful. But it's a different problem — it's about deployment completeness, not file quality. A future version might add it as another opt-in flag.
Wrapping up
The whole tool is about 350 lines of actual logic across three modules. No dependencies, no configuration, no build step. It catches the nine issues that have caused me real debugging time in real projects. If it saves you one hour of "why is staging hitting the production database," it's done its job.
The code is at github.com/sen-ltd/dotenv-lint. It's MIT-licensed, stdlib-only Python 3.10+, and has 38 tests.