FileNotFoundError: Fix Missing Paths in Python
FileNotFoundError means the path is wrong, not the file.
20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.
- ✓Opening files with
open()and reading rows in a loop - ✓Running scripts from both an editor and the terminal
- ✓Basic directories: relative versus absolute path ideas
- FileNotFoundError means Python resolved your path against the wrong base — usually the process cwd instead of your script's directory — so the file was never where you looked.
- Fix it now: anchor to the script with
Path(__file__).parent / 'data' / 'f.csv'instead of a bare relative path like'data/f.csv'. - You'll tell cwd drift from typos by printing
Path.cwd()andPath(p).resolve()before the open, then choosing try/except for missing data versus a startup assert for required files. - Remember Linux paths are case-sensitive while Mac and Windows forgive case — verify exact names with
lsbefore assuming the code is wrong.
It's like giving directions from the wrong starting point. 'Two blocks north' works from your house but fails from the office — the steps are right, the origin is wrong. Relative file paths work the same way: data/f.csv means 'from wherever Python is standing right now', and that spot changes with how you launched it. The file never moved; you navigated from the wrong corner. Navigate from a fixed landmark — your script's own folder — so the directions work no matter where Python starts.
Your script opens data/rates.csv flawlessly from your editor, then crashes with FileNotFoundError the moment cron, Docker, or a teammate runs it. The file exists. The path looks right. The difference is invisible: each launcher starts Python in a different working directory, and your relative path resolves against that directory — not against your script. Same code, three launchers, three different files attempted.
This is the most common path bug in Python, and it wastes hours because developers interrogate the file instead of the base. They re-check spelling, re-create the file, and add exists() checks that confirm the file is missing from the wrong place — which they already knew. The missing piece is always the resolution base: print Path.cwd() once and the mystery usually ends in seconds.
You'll learn to anchor paths to __file__, choose between pre-checks and try/except deliberately, wield pathlib's resolve for the truth, and handle the case-sensitivity gap between Linux and laptops. By the end, your file opens will work from editors, cron, containers, and CI without a single hard-coded absolute path.
cwd vs Script Dir: The Two Bases Behind Every Relative Path
Every relative path resolves against exactly one base: the process working directory returned by Path.cwd(). Not your script's folder, not the project root, not where your editor shows the file — the directory the launcher stood in when Python started. Run from /srv/pricing and data/rates.csv means /srv/pricing/data/rates.csv. Run the same script from /opt/runner and it means /opt/runner/data/rates.csv. The path literal never changed; the world under it moved.
Launchers move the world constantly. Editors usually set cwd to the project root. Terminals use wherever you cd'd. Cron uses the crontab owner's home or a wrapper's directory. Docker uses the image's WORKDIR. Systemd uses its WorkingDirectory or /. Each is a different base with a different answer for the same literal, which is why 'works on my machine' is the signature symptom of this error.
The diagnostic is one print: log Path.cwd() and Path(target).resolve() at startup, before any open. The resolve() call shows the absolute file Python will attempt — compare it to the absolute path from ls and the gap stares back at you. When they differ, fix the base (anchor to __file__), not the literal. And internalize the rule: bare relative paths are launcher-dependent by definition, so production code treats them as bugs waiting for a new wrapper.
Path.cwd() would have shown the base move on night one instead of day six.resolve() at startup; anchor production paths to the script dir.Anchor With __file__ and pathlib: Paths That Survive Any Launcher
Path(__file__).resolve().parent gives you the directory containing the current script as an absolute path, immune to cwd. Compose from there with the / operator: BASE / 'data' / 'rates.csv'. The result works from editors, terminals, cron, Docker, and CI without modification, because the anchor travels with the code instead of depending on the launcher.
Resolve once at module top and reuse the constant. Calling .resolve() also normalizes .. segments and symlinks, so jobs/load_rates.py and its symlinked deploy copy agree on one canonical base. For files relative to the project root rather than the script, climb with .parent as needed — Path(__file__).resolve().parent.parent for a jobs/ script reaching the root — and name the constant PROJECT_ROOT so readers see the intent.
Two cautions. Frozen binaries and zip imports can set __file__ oddly; guard those with a getattr fallback to Path.cwd() plus a loud log. And never anchor user-supplied absolute paths to the base — if the user passes /etc/rates.csv, use it verbatim. Anchor relative inputs, respect absolute ones, and your loader works everywhere without a single hard-coded machine path.
Add one Linux CI job that imports the module and prints every anchored constant so a bad climb breaks the build instead of the 2 a.m. load.
exists() Pre-Check vs try/except: Diagnose With One, Ship the Other
Path.exists() answers 'is something there right now' — perfect for diagnostics and startup validation of required config. It tells you before the traceback whether the file is absent, and combined with .is_file() versus .is_dir() it distinguishes 'missing' from 'you pointed at a directory'. Use it in debug prints and in fail-fast startup asserts that refuse to run a 2-hour job without its inputs.
But exists() before open() has a race: the file can vanish between the check and the open (the TOCTOU gap), and in concurrent pipelines it does. For data files that may legitimately be absent — optional vendor drops, date-partitioned inputs — wrap the open in try/except FileNotFoundError and handle the miss in the handler: skip, fall back explicitly, or quarantine. The handler covers check and use atomically, so no race and no duplicated path logic.
The rule of thumb: required-at-startup files get an assert-exists gate with the resolved path in the message; optional-at-runtime files get try/except around the operation. The rate loader's sin was neither — it used try/except with a silent stale fallback. Keep the try/except shape, but make the handler loud: page, require a flag, or refuse — never quietly reuse yesterday.
exists(), gate required files with asserts, and handle optional files with try/except. Never let a missing-file handler silently substitute stale data.pathlib resolve(): See the Truth Before You Open
Path.resolve() converts any path to its absolute canonical form — base applied, .. collapsed, symlinks expanded — without touching the filesystem's contents. Printing resolve() before open() shows exactly which file Python will attempt, which ends typo-versus-base debates in one line. The attempted path in the FileNotFoundError message is this same resolution, so logging it proactively moves the answer from the traceback to your startup log.
Resolve also exposes symlink surprises. Deploy systems that symlink /srv/pricing/current to a dated release mean your script's real directory differs from its logical one; .resolve() follows the link so data paths stay consistent across deploys, while non-resolved parents can point at the link path and drift. Standardize on resolve() at the anchor and every derived path inherits the canonical base.
Use resolve() in three places: the startup log (cwd plus each input's resolution), the error handler (log the resolved attempted path with ls of its parent), and tests (assert anchored paths equal expected absolutes). When the next FileNotFoundError arrives, the log already contains the full truth — attempted file, actual directory listing, and the base that produced it.
Path.cwd() plus every input's resolve() before the first open. When the wrapper moves your base, night one's log shows the new attempted path immediately — no traceback archaeology needed.resolve() log plus a freshness assert would have converted those warnings into a 2:20 a.m. page on night one.Case-Sensitivity: Linux Remembers What Laptops Forgive
Linux filesystems are case-sensitive: Rates.csv and rates.csv are two different files. Mac's default APFS and Windows' NTFS are case-insensitive but case-preserving: they store the case but match any case on lookup. Code developed on a Mac that opens rates.csv while the repo holds Rates.csv passes every laptop test and crashes on the first Linux deploy — a green-to-red gap with no code change.
The fix starts with exactness: list the directory on Linux with ls and match the literal byte-for-byte, including case and any trailing spaces. Then lock it in — rename the file to the lowercase convention your code uses, or fix the literal to the canonical name, and add a CI check that opens every referenced data file on a Linux runner. The CI run is the enforcement; human memory for case is not a control.
Watch the cousins of case: trailing whitespace in filenames from Windows exports, composed versus decomposed Unicode in accented names, and extension drift (.CSV versus .csv). Each passes forgiving filesystems and fails strict ones. A startup gate that asserts each required file's existence on Linux catches the whole family before the job burns two hours.
Standardize data filenames to lowercase with hyphens at creation time so case, spaces, and extension drift never enter the repo in the first place.
is_file() on a Linux runner, catching case drift at merge time instead of 2 a.m.Stop Hand-Rolling Joins: Slashes, Dots, and Missing Parents
String-concatenated paths break in three quiet ways: missing separators ('data' + 'rates.csv'), doubled separators from trailing slashes, and no parent creation before writes. Each works on the author's machine and fails elsewhere, producing FileNotFoundError on read or on write when the parent directory doesn't exist. Pathlib's / operator and os.path.join eliminate the first two; mkdir(parents=True, exist_ok=True) eliminates the third.
Writes deserve the same anchoring as reads. Before writing reports, ensure the output directory exists with anchored_out.parent.mkdir(parents=True, exist_ok=True) — one line that survives fresh containers and new deploy roots. Log the resolved output path alongside the input paths so a missing-parent failure names the directory to create.
Finally, normalize user-supplied path fragments: strip whitespace, reject absolute escapes when only relative names are expected, and join them to the anchor rather than trusting them. A fragment of ../etc/passwd joined naively escapes your data directory; checking that the resolved result stays under the anchor (resolved.is_relative_to(anchor) on Python 3.9+) keeps file access fenced. Clean joins plus ensured parents remove the last mechanical causes of this error.
Cron's cwd Moved and the 2 a.m. Rate Load Missed 6 Days
Path.cwd(). Code review never examined the wrapper's working directory, and the loader's except FileNotFoundError: fallback to yesterday's table made the miss invisible by design.- Anchor file paths to __file__, never to cwd; one wrapper update moved the base and 6 nightly loads read stale data with exit 0.
- Assert data freshness, not just job success; 6 green runs served 6-day-old rates because no check compared max(rate_date) to now.
- Make stale fallbacks explicit and loud; a silent except-to-yesterday path needs a flag plus a page, not a warning nobody reads.
python -c "from pathlib import Path; print('cwd:', Path.cwd()); print('script dir:', Path('jobs/load_rates.py').resolve().parent)" then ls -la /srv/pricing/data/rates.csv /opt/runner/data/rates.csv 2>&1 | head. If the cwd differs between launches, anchor with Path(__file__).resolve().parent and never rely on the launcher's directory.python -c "from pathlib import Path; p=Path('data/rates.csv'); print('cwd:', Path.cwd()); print('would open:', p.resolve())" and ls data/ | head -20 from the same shell. If resolve() points outside your project while ls shows the file elsewhere, the spelling is fine and the base is wrong.python -c "from pathlib import Path; p=Path('/tmp/x.csv'); print(p.exists(), p.is_file())" for diagnostics, but ship try/except FileNotFoundError around the open for data files (handles TOCTOU races). Reserve startup asserts with ls output for required config that must exist before the job does anything.ls /srv/pricing/data/ | cat -v and python -c "from pathlib import Path; print([p.name for p in Path('/srv/pricing/data').iterdir() if 'rate' in p.name.lower()])". If the directory holds Rates.csv but code asks rates.csv, Mac forgives and Linux raises — rename or fix the literal to the exact case.python -c "print('data' + '/' + 'rates.csv'); from pathlib import Path; print(Path('data') / 'rates.csv')" then grep -rn "+ '/'+" jobs/ | head -10 to find hand-rolled joins. Replace with / operators or os.path.join so separators and trailing slashes behave on every OS.| File | Command / Code | Purpose |
|---|---|---|
| files_cwd_vs_script.py | from pathlib import Path | cwd vs Script Dir |
| files_anchor.py | from pathlib import Path | Anchor With __file__ and pathlib |
| files_exists_vs_try.py | from pathlib import Path | exists() Pre-Check vs try/except |
| files_resolve.py | from pathlib import Path | pathlib resolve() |
| files_case.py | from pathlib import Path | Case-Sensitivity |
| files_joins.py | from pathlib import Path | Stop Hand-Rolling Joins |
Key takeaways
resolve() before debugging the literal.exists(), gate required files with asserts, handle optional files with try/exceptCommon mistakes to avoid
5 patternsRe-checking spelling instead of printing the base
Path.cwd() silently points at /opt/runner instead of /srv/pricing.resolve() first; fix the anchor, not the filename.exists() check then open() with silent fallback
Hard-coding absolute machine paths
Developing on Mac with mismatched case
Writing into date-partitioned dirs without mkdir
Interview Questions on This Topic
Why does a relative path work in your editor but fail under cron?
Path.cwd() and resolve() to prove it, then anchor to Path(__file__).parent.Frequently Asked Questions
20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.
That's Errors. Mark it forged?
5 min read · try the examples if you haven't