skip to content

Why is checking os.path.exists() before open() weaker than catching FileNotFoundError?

level: middleimportance: must knowfreq 45%

answer

  1. Two system calls with a gap between them
  2. The filesystem can change under you
  3. The boolean answer is already historical
  4. False can also mean you cannot stat it
  5. O_CREAT | O_EXCL makes create-or-fail atomic

basics

~20 s

The check and the open are two separate system calls, and the filesystem can change in between, so a True answer does not promise the open will succeed. os.path.exists() also answers False for a path you simply cannot stat, and it says nothing about whether the path is a file you can read.

solid answer

~50 s

`os.path.exists()` reports on the filesystem at the instant it runs; `open()` acts on the filesystem an instant later. Anything can happen in that window — another process deletes, renames or replaces the path, a directory appears where a file was — so the guard's answer is already historical when you act on it. It is also a weaker answer than it looks: `os.path.exists()` returns `False` for a broken symlink and for any path whose `stat` call fails, including permission errors, and returns `True` for directories. So the guard neither removes the need for a handler nor gives you the information you wanted. The EAFP form asks the kernel to resolve and open the path in one atomic operation and then handles the outcomes that matter — `FileNotFoundError`, `PermissionError`, `IsADirectoryError` — with a specific exception that names the real cause.

code

python · 12 lines
python
import os

path = 'invoice-000042.pdf'
try:
    fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
    print('already rendered')
else:
    with os.fdopen(fd, 'wb') as out:
        out.write(b'%PDF-1.7\n')
    print('rendered')
    os.remove(path)

go deeper

for a junior

Know that os.path.exists() only describes the filesystem at the moment it runs, and that opening the file inside a try block and catching FileNotFoundError is the normal Python way to read a file that may be absent.

for a middle

Explain the mechanics: two independent system calls with a gap between them, exists() following symlinks and answering False on any stat failure, exists() answering True for a directory, and the named OSError subclasses that carry the real cause.

for a senior

Demonstrate the operational fixes — os.open with O_CREAT|O_EXCL for create-or-fail, os.replace for atomic publish, write-to-temp-then-rename — and be able to say why an in-process lock does not help across processes.

for a principal

Own the convention for how services touch shared storage: which operations must be atomic, where retries and idempotence keys live, and how you keep check-then-act patterns out of jobs that several workers run concurrently.

### The shape of the problem A very common piece of transliterated Python looks like this: ```python import os if os.path.exists(path): with open(path) as handle: data = handle.read() else: data = None ``` The EAFP form is: ```python try: with open(path) as handle: data = handle.read() except FileNotFoundError: data = None ``` The second is not merely shorter. The first is *unreliable* in three distinct ways, and being able to separate them is what the question is really testing. ### 1. The check and the use are two operations with a gap between them `os.path.exists()` performs a `stat` on the path and returns a boolean about a moment that has already passed by the time the function returns. `open()` then performs its own, independent resolution of the same path. Between those two system calls the process can be descheduled for an arbitrarily long time, and the filesystem is shared, mutable, global state: another process, another thread, a cleanup job, a deploying container, or an operator can delete the file, rename it, replace it with a directory, or swap it for a symlink pointing elsewhere. The consequence is blunt: `os.path.exists()` returning `True` does not promise the following `open()` succeeds, and returning `False` does not promise it would have failed. You still have to handle `FileNotFoundError` on the `open()` line — which means the guard did not remove a failure path, it added a branch that gives false confidence. This is the general staleness property of any look-before-you-leap check over shared mutable state; on a filesystem the window is wide enough to hit in normal operation, not just under adversarial conditions. The fix is not "check twice" or "take a lock" — a lock in your process says nothing about other processes. The fix is to make the decision and the action *one* operation and let the kernel resolve it atomically, which is exactly what `open()` does. EAFP is not a style preference here; it is the only form that can be correct. ### 2. The guard answers a different question than you asked `os.path.exists()` follows symlinks and returns `False` if the underlying `stat` raises `OSError` — so it also answers `False` for a **broken symlink**, for a path inside a directory you lack execute permission on, and for a path whose name the operating system rejects. Your code then takes the "file is not there" branch for a situation that is actually "the file exists and you cannot see it", and the real cause is gone. Meanwhile `exists()` returns `True` for **directories**, so the guard passes and `open()` raises `IsADirectoryError` anyway. The exception carries the information the boolean threw away. `FileNotFoundError`, `PermissionError`, `IsADirectoryError` and `NotADirectoryError` are all `OSError` subclasses (split out into named classes in Python 3.3 by PEP 3151), each carrying `errno`, `strerror` and `filename`. Catching the one you can actually handle and letting the others propagate produces a much better failure than a boolean that collapses all of them into `False`. ### 3. Some intents cannot be expressed as check-then-act at all Consider "create this output file, but fail if it already exists" — the classic idempotence guard in a batch job. Written as `if not os.path.exists(out): open(out, 'w')`, two workers can both see `False` and both proceed, and the second silently truncates the first one's work. There is no arrangement of checks that fixes it, because the check and the create are separate. The correct form pushes the whole decision into one kernel call: ```python fd = os.open(out, os.O_CREAT | os.O_EXCL | os.O_WRONLY) ``` `os.O_EXCL` makes creation fail with `FileExistsError` if the path already exists, atomically. Similarly, `os.replace()` gives an atomic rename over an existing destination, which is how you publish a finished file without a window where a reader can see a half-written one: write to a temporary name, then replace. ### What good code actually looks like Attempt the operation; catch the specific `OSError` subclasses you have a real response for; keep the `try` around only the call that can fail, so a bug in your parsing code is not classified as a missing file: ```python try: handle = open(path, 'rb') except FileNotFoundError: return None with handle: return parse(handle) ``` When the intent truly is "remove this if it happens to be there", `contextlib.suppress(FileNotFoundError)` states that intent in one line and names the exception it is ignoring, which is far better than a bare `except: pass`. ### Where a check is still fine LBYL on the filesystem is not banned. A pre-flight check at startup — "does the config directory exist, is the output directory writable" — is a legitimate use: you are producing a clear diagnostic early, not relying on the answer to make a later operation safe. The rule is that a check may *inform* you; it may never be the thing that makes the subsequent call correct.

  • Does catching FileNotFoundError eliminate the race, or just move it?
    It eliminates the check-then-act gap, because there is no longer a separate check: the kernel resolves the path and opens it in one atomic operation, and the exception reports the outcome of that single operation. The file can of course still be deleted a millisecond after a successful open, but your file object already refers to the opened file on POSIX systems, so you keep reading it. What disappears is the window between deciding and acting.
  • How do you publish a generated file without a reader ever seeing it half-written?
    Write to a temporary path in the same directory, flush and close it, then call os.replace() to move it onto the final name. os.replace() is an atomic rename on the same filesystem and overwrites an existing destination, so any reader sees either the old complete file or the new complete file, never a partial one. Writing directly to the final path leaves a window where the file exists but is incomplete.
  • Which exceptions should the caller actually catch when opening an input path?
    Catch the ones you have a real response to. FileNotFoundError for a genuinely optional input, PermissionError when you can fall back or report a configuration problem, IsADirectoryError if a path could be misconfigured. If the response is the same for all of them, catch OSError once and inspect exc.errno or exc.filename in the message. Do not catch Exception here: that also swallows the bugs in the surrounding code.

Phoning ahead to ask whether a parking space is free tells you about a moment that has passed by the time you hang up; driving there and dealing with whatever you find is the only answer that is true when it matters.

saying these in an interview costs you the question

  • Says os.path.exists() returning True guarantees open() will succeed
  • Treats a False result as proof the path is absent
  • Suggests a threading lock fixes a cross-process filesystem race
  • Thinks the guard removes the need for a handler on open()
  • Uses check-then-create for idempotence instead of O_EXCL
  • Assumes os.path.exists() distinguishes files from directories

context