I spent forty-eight hours listing the same config directory, because every Python snippet I copied used Path.home() without comment. The remote job printed a successful write, and my laptop still showed an empty folder after every refresh. Have you ever assumed tilde expansion and pathlib would agree, just because they agreed on your laptop? I had assumed that, and the assumption survived code review, CI badges, and a very confident agent summary.
This is a compressed field notebook, not a product tour, and every command below is something you can rerun. I am writing it because user-level config is where laptops lie most politely. If you strip every tool name out of this piece, the debugging sequence should still stand on its own.
Hour 0: the story I wanted to believe
I was wiring a small CLI that stored a JSON profile under a user config directory, which felt boring enough to skip. Locally, ls ~/.config/myapp/profile.json showed the file after every run, so I stopped printing the resolved path. Why would I print a path that pathlib had already resolved in a way that matched my shell? That question is how I donated two days to the wrong folder.
The remote worker kept saying the write succeeded, and the test that opened Path.home() / ".config" / "myapp" / "profile.json" stayed green. I kept grepping my laptop home, because that is the directory I can see in a file manager. Nothing I listed there ever changed, which I interpreted as a network problem, a volume mount problem, and then a silent permission problem.
What I actually tried
I did not start with process identity, which is the part I would now repeat first. I started with folklore, then with slightly more folklore, then with a model-generated test that trusted the same API I already trusted.
- I reran the CLI under the same shell alias I use every morning, then listed
~/.config/myappuntil the listing felt like a ritual. - I printed
os.getcwd()only, which looked like the project root, so I decided the working directory could not be the bug. - I added
print("wrote profile")after thewrite_textcall, which proved a write happened somewhere I was still not listing. - I copied the JSON into the repo as a golden file, then taught the test to round-trip through
Path.home(), which made the suite even more confident. - I blamed the remote filesystem, then the container user, then a cache, because those explanations keep you busy without requiring a dump of
HOME.
Does any of that sound like debugging, or does it sound like protecting a favorite mental model? It was the second one, dressed up as the first.
What broke, once I finally printed the homes
The break was not a missing library, and it was not a stale .pyc file from another tree. Three different “homes” existed in one process, and only one of them received the write.
-
os.environ.get("HOME")on the worker was a job-specific directory I had never opened in a terminal. -
pwd.getpwuid(os.getuid()).pw_dirstill pointed at the login directory from the image, which my eyes kept treating as truth. -
Path.home()followed the environment variable when it was set, so the test and the CLI agreed with each other while disagreeing with my laptop. -
os.path.expanduser("~")matchedPath.home()on the worker, which made the bug look internally consistent and therefore finished.
I had been listing the passwd directory with a human shell, while the job wrote into the environment directory with a non-interactive process. Can a green test lie to you while remaining locally consistent? Yes, if every assertion walks through the same wrong home.
On a laptop, those four values collapse into one string so often that tutorials never mention the split. On a free server, a CI runner, or a shared image, they split the first time a scheduler injects HOME for isolation. Python is not wrong here; pathlib.Path.home() documents that it uses HOME when present. I had just never printed the winner.
Artifact: dump the identity before you trust a write
Label this as a runnable receipt, not as a benchmark, because it only prints facts about one process. Save it as dump_home_identity.py and run it in every environment you still trust by habit.
#!/usr/bin/env python3
"""Print every home-like path a Python process might use for user config."""
from __future__ import annotations
import json
import os
import pwd
import sys
from pathlib import Path
def _safe_home() -> tuple[str | None, str | None]:
try:
return str(Path.home()), None
except RuntimeError as exc:
return None, f"{type(exc).__name__}: {exc}"
def dump_identity() -> dict:
path_home, path_err = _safe_home()
try:
passwd_dir = pwd.getpwuid(os.getuid()).pw_dir
except (KeyError, OSError) as exc:
passwd_dir = f"error:{type(exc).__name__}"
candidates = {
"HOME": os.environ.get("HOME"),
"XDG_CONFIG_HOME": os.environ.get("XDG_CONFIG_HOME"),
"Path.home()": path_home,
"expanduser_tilde": os.path.expanduser("~"),
"passwd_dir": passwd_dir,
}
unique = {value for value in candidates.values() if value and not str(value).startswith("error:")}
return {
"pid": os.getpid(),
"uid": os.getuid(),
"euid": os.geteuid(),
"cwd": os.getcwd(),
"executable": sys.executable,
"argv": sys.argv,
"Path.home_error": path_err,
"candidates": candidates,
"unique_existing_homes": sorted(unique),
"homes_agree": len(unique) <= 1,
"config_guess": str(
Path(os.environ["XDG_CONFIG_HOME"]) if os.environ.get("XDG_CONFIG_HOME")
else Path(path_home or ".") / ".config"
),
}
if __name__ == "__main__":
print(json.dumps(dump_identity(), indent=2, sort_keys=True))
Run the same file three ways before you change application code, because the disagreement usually appears in the launch method, not in the JSON encoder.
python dump_home_identity.py
env -u HOME python dump_home_identity.py
env HOME=/tmp/job-home XDG_CONFIG_HOME=/tmp/job-home/config python dump_home_identity.py
I now treat homes_agree: false as a failed pretest, even when the product test is green. If you only run the dump on your laptop, you will keep reproducing the laptop.
A pytest that fails when the write would vanish
This test is deliberately rude. It does not check JSON schema. It checks that the config root is an explicit directory you passed in, not a home the runtime invented.
# test_config_root.py
from __future__ import annotations
import json
import os
from pathlib import Path
import pytest
from dump_home_identity import dump_identity
def write_profile(config_root: Path, payload: dict) -> Path:
config_root.mkdir(parents=True, exist_ok=True)
target = config_root / "profile.json"
target.write_text(json.dumps(payload, indent=2), encoding="utf-8")
return target
def test_profile_does_not_use_implicit_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("HOME", str(tmp_path / "attacker-home"))
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
explicit_root = tmp_path / "app-config"
written = write_profile(explicit_root, {"ok": True})
identity = dump_identity()
assert written.exists()
assert written.parent == explicit_root
assert Path.home() != explicit_root
assert identity["candidates"]["HOME"].endswith("attacker-home")
assert not (Path.home() / ".config" / "profile.json").exists()
If your production function still calls Path.home() internally, this test will not save you, and that is the point. Pass the config root in, or the next worker will pick a new home without asking.
Decision table I wish I had on hour two
| What you see | Where you looked | Where the process wrote | What to print next |
|---|---|---|---|
| File missing after a green test |
~/.config/app in an interactive shell |
$HOME/.config/app from the worker env |
Path.home(), os.environ.get("HOME")
|
| File appears, then disappears on the next job | A shared login home from /etc/passwd
|
A per-job HOME that the scheduler deletes |
pwd.getpwuid(os.getuid()).pw_dir |
| Agent claims it saved settings | The repo, because that is what git status shows |
Path.home() outside the workspace |
dump_home_identity.py on the same argv |
| Local pass, remote pass, laptop empty | Your desktop file manager |
XDG_CONFIG_HOME when it is set, else ~/.config
|
os.environ.get("XDG_CONFIG_HOME") |
| Expanduser looks fine in a REPL | A REPL that inherited your desktop environment | A systemd unit, cron job, or container with a different env |
env -0 / os.environ inside the job |
Would I still grep ~/.config first if I had this table taped next to the terminal? I would grep it second, after the dump.
Where a free model and a free server made the lie visible
Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free model access to draft the round-trip test, then ran that test on the free server option because I wanted a machine that was not my laptop. The generated test stayed green, which was honest in a narrow way: it wrote through Path.home() and then read the same path back. The free server was useful precisely because its HOME was not mine, and the dump script is what made that difference boring instead of mystical.
I am not claiming a model name, a quota, a hardware profile, or a speedup, because I did not measure those things for this note. I am claiming that an unprinted home will survive any assistant that keeps using Path.home() as if it were a project directory. If you already dump process identity before you trust a remote green, you do not need another product in the loop.
What I would repeat in the next forty-eight hours
I would print the identity dump in the same process that writes config, not in a helper script I ran later from a different shell. I would pass an explicit --config-root into the CLI, defaulting to a directory inside the workspace during tests. I would refuse any agent patch that introduces Path.home() without also printing the resolved path next to the write. I would keep the decision table, because the next mismatch will look like permissions again.
- Repeat: one JSON dump of
HOME,XDG_CONFIG_HOME,Path.home(), and passwd dir on every worker. - Repeat: config roots as function arguments, not as globals derived from tilde expansion.
- Repeat: a test that sets a poisonous
HOMEand still writes into a tmp_path you control. - Do not repeat: believing a file manager that is attached to a different user environment than the job.
Limitations, and who should skip this
This workflow does not manage secrets, and a free server home directory is the wrong place for tokens even when the dump looks clean. It also does not replace platformdirs if you need XDG, Windows, and macOS conventions done carefully. If you are writing a multi-user daemon, Path.home() was never your API, and this article should not talk you into using it.
Skip this approach when the process must not write user-level files at all, including cache files that happen to live under ~. Skip it when you cannot inspect environment variables on the worker, because the dump becomes fiction the moment you reconstruct it from a laptop. Skip it when the real bug is an application default that should be in the repo, not in any home.
Python will keep resolving Path.home() from the environment, and schedulers will keep injecting HOME so jobs cannot clobber each other. The only durable habit is to print the path you wrote, in the process that wrote it, before you spend forty-eight hours listing a directory your shell still loves.
Top comments (0)