DEV Community

Taylor Wang
Taylor Wang

Posted on

48-Hour Field Notes: load_dotenv Returned True. The Stale Export Still Won.

Have you ever trusted a startup log that claimed your .env file loaded without a single complaint? I did, and I spent two long days chasing the wrong Redis host because of that boolean. The process printed a friendly success path, then connected somewhere my repository did not mention at all. Why would a clean looking config loader lie while the application kept talking to yesterday's machine?

I am treating this as field notes, not as a postmortem with fake dashboards or invented latency charts. I want the sequence of mistakes written down before I forget the order. If you have a laptop that has been "helpful" for years, you already know this flavor of bug.

The symptom that wasted the first afternoon

My laptop could reach Redis without drama, while a clean checkout kept timing out on the same commit. I checked Docker port mapping, I checked the compose healthcheck, and I even restarted the daemon like a person with no better ideas. The client timeout looked identical in both places, so I assumed the library was flaky under load. Have you noticed how quickly we blame the client when the parent environment is the actual liar?

The log line was the trap that kept me polite and wrong. It printed the .env path, it printed loaded=True, and it never printed the value that actually won the lookup. I had taught myself to trust that boolean the way I trust a green healthcheck. That was the first mistake, and it cost me a full afternoon of packet captures I did not need.

What I tried before I questioned the shell

I am listing the failed hypotheses because I will otherwise repeat them next month.

  1. I blamed the Redis image tag and pinned an older one, but the timeout did not move at all.
  2. I blamed Docker DNS and replaced the hostname with 127.0.0.1, yet my laptop still used the wrong box.
  3. I blamed a BOM in .env and re-saved the file as UTF-8, while Python still printed loaded=True.
  4. I blamed python-dotenv version drift and reinstalled the package, and the connection target still did not change.
  5. I printed os.environ.get("REDIS_HOST") too late, after other imports had already cached a client object.

That fifth item was closer to the truth, but I still refused the basic question. Who exported REDIS_HOST before Python even started? I kept staring at application code because application code is where I feel competent. The parent shell is where I feel slightly ashamed.

Why the success boolean is a bad contract

load_dotenv() telling you True means it found a file and parsed lines. It does not mean the file won against the process environment. The default in python-dotenv is override=False, which is documented behavior rather than a surprise if you read it once. I had not read it. I had only read my own log format, which is a generous way of saying I wrote a lie and then believed it.

The clean box that refused to inherit my bashrc

I needed a process that did not source interactive startup files on my laptop. This machine is a museum of forgotten export lines, and I keep pretending it is a clean workstation. I copied the repo onto a throwaway Linux environment instead of arguing with my own shell for another night. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free model access to draft a small env-source tracer, then ran that tracer on the free server option so the process would not inherit my laptop profile.

The clean process read .env and connected where the file said to connect. My laptop ignored the same file and kept the stale host from bashrc. Same commit, same .env, different parent environment, and suddenly the packet captures felt embarrassing. I did not need a benchmark chart. I needed a diff of sources before any client object was constructed.

If you need a throwaway Linux shell for the same env diff, that free server option was enough for this tracer and nothing more.

The artifact: an env-source tracer you can run first

This script does not mutate the process, and it does not construct a Redis client. It only reports which layer would win for a given key. Save it as env_trace.py and run it with the same interpreter your app uses.

#!/usr/bin/env python3
"""Report env-key precedence without loading application clients."""
from __future__ import annotations

import os
import sys
from pathlib import Path

KEYS = ("REDIS_HOST", "REDIS_PORT", "DATABASE_URL")


def parse_dotenv(path: Path) -> dict[str, str]:
    values: dict[str, str] = {}
    if not path.is_file():
        return values
    for raw in path.read_text(encoding="utf-8").splitlines():
        line = raw.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, _, value = line.partition("=")
        key = key.strip()
        value = value.strip().strip("'").strip('"')
        if key:
            values[key] = value
    return values


def main() -> int:
    cwd = Path.cwd()
    dotenv = parse_dotenv(cwd / ".env")
    print(f"python={sys.executable}")
    print(f"pid={os.getpid()} ppid={os.getppid()}")
    print(f"cwd={cwd}")
    print("---")
    for key in KEYS:
        exported = os.environ.get(key)
        file_val = dotenv.get(key)
        if exported is not None:
            winner = "process-environment (dotenv will not override by default)"
            shown = exported
        elif file_val is not None:
            winner = ".env file (only if something calls load_dotenv)"
            shown = file_val
        else:
            winner = "unset"
            shown = ""
        print(f"{key}")
        print(f"  exported={exported!r}")
        print(f"  dotenv={file_val!r}")
        print(f"  winner={winner}")
        print(f"  shown={shown!r}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

The three commands that split the lie from the file

Run the tracer in both places before you import your Redis client. The third command is the one that made my laptop honest.

python env_trace.py
bash -lc 'python env_trace.py'
env -i HOME="$HOME" PATH="$PATH" python env_trace.py
Enter fullscreen mode Exit fullscreen mode

env -i drops the inherited junk and leaves you a nearly empty environment. If the host changes only under env -i, your shell profile is the bug, not the library. bash -lc is useful too, because a login shell sources a different set of files than the shell inside your terminal emulator. Have you ever confirmed which of those files your IDE actually uses when it launches Python?

A tiny pytest file documents the default python-dotenv behavior so nobody on the team has to relearn it during an outage. These tests are a contract for the library default. They are not a claim about my laptop being clean.

# test_dotenv_does_not_override.py
import os
from pathlib import Path

from dotenv import load_dotenv


def test_load_dotenv_keeps_preexisting_value(tmp_path: Path, monkeypatch):
    env_file = tmp_path / ".env"
    env_file.write_text("REDIS_HOST=from-file\n", encoding="utf-8")
    monkeypatch.setenv("REDIS_HOST", "from-shell")
    loaded = load_dotenv(env_file)
    assert loaded is True
    assert os.environ["REDIS_HOST"] == "from-shell"


def test_override_flag_is_explicit(tmp_path: Path, monkeypatch):
    env_file = tmp_path / ".env"
    env_file.write_text("REDIS_HOST=from-file\n", encoding="utf-8")
    monkeypatch.setenv("REDIS_HOST", "from-shell")
    loaded = load_dotenv(env_file, override=True)
    assert loaded is True
    assert os.environ["REDIS_HOST"] == "from-file"
Enter fullscreen mode Exit fullscreen mode

I would rather keep that file in the repo than keep a wiki paragraph that nobody reads. The next person will trust loaded=True the same way I did. Can you blame them, when the log looks so confident?

Decision table I wish I had on hour one

Observation Likely layer What I would run next
Value changes under env -i Interactive shell profile grep -n REDIS ~/.bashrc ~/.profile ~/.zshrc
Value matches .env only on a clean host Inherited process environment Print allowlisted keys before any import
Value missing in systemd, present in SSH Non-interactive environment Check Environment= and EnvironmentFile=
Value missing in cron, present in a TTY Login versus non-login shells Compare bash -lc with bash -c
Value changes after the first import Module-level client cache Trace who constructed the client
load_dotenv is True, value still wrong Default override=False Run the two tests above, then stop guessing

I keep this table above the packet sniffer now. Network tools are fine when packets are actually wrong. They are a slow config tracer when the parent environment already chose the host.

What actually broke

python-dotenv loaded the file. It refused to override a variable my bashrc had exported during a forgotten Docker experiment. The success boolean was about file parsing, not about winning values. My laptop looked healthy because the stale host was still alive on the LAN. The clean server had no such export, so it followed the repository.

Was the library wrong? No. Was my log line wrong? Yes, because it logged the parse result and hid the precedence. I would rather print source=process-environment than print loaded=True. I would also rather fail startup when an allowlisted key is inherited from a profile I do not control.

A quieter startup log I would actually trust

def describe_key(key: str, dotenv_values: dict[str, str]) -> str:
    if key in os.environ:
        return f"{key} source=process-environment"
    if key in dotenv_values:
        return f"{key} source=dotenv-file"
    return f"{key} source=unset"
Enter fullscreen mode Exit fullscreen mode

That helper still does not print secret values. It prints the layer, which is the only thing that would have saved me on day one. If you need the value, print it locally, never into a chat transcript, and never into a shared log drain.

What I would repeat

I would run the tracer before I open Wireshark. I would run env -i before I pin a container tag. I would copy the repo onto a machine that has never sourced my bashrc. I would keep secrets out of tracer output by printing only allowlisted keys. And I would make the unit test part of the pull request, because the next person will trust that boolean too.

Would I paste a full os.environ dump into a model? No. Names of non-secret keys are enough for drafting a tracer. Values that look like hosts, ports, or tokens are already too much if your threat model cares. The model helped me draft the tracer. It did not get a production secret, and it should not.

I would also grep startup files with a boring command instead of rereading Docker docs for the third time:

grep -nE 'export (REDIS_|DATABASE_|API_)' ~/.bashrc ~/.profile ~/.bash_profile ~/.zshrc 2>/dev/null
Enter fullscreen mode Exit fullscreen mode

If that command prints a hit, you can stop accusing the image tag. The hit is the bug, even when the value still "works" on your laptop.

Limitations, and who should skip this

This workflow is for local config lies, not for rotating production credentials. Do not run the tracer against a live secret store and then copy the output into chat. Do not treat a free server as a production Redis, a long-lived worker, or a place to store .env files with real tokens. The approach also fails if your application reads config from a remote service after import time. In that case the process environment is not the winner, and this table will mislead you.

Skip this if you cannot allowlist keys. Skip this if your compliance rules forbid sending even key names to a coding model. Skip this if the bug is a TLS hostname mismatch, because env precedence will not fix a certificate. Skip this if systemd is injecting EnvironmentFile= and you have never opened the unit. That is a different layer, and the tracer will only tell you the process already lost.

I am still going to keep forgotten exports in my bashrc, because I am human. I am just going to run env -i before I accuse Docker again.

Top comments (0)