skip to content

Why can os.IsNotExist return false for an error that errors.Is(err, fs.ErrNotExist) matches?

level: middleimportance: should knowfreq 54%

answer

  1. one of the two is older than wrapping
  2. it looks at the concrete type
  3. it peels one layer, not a chain
  4. a %w wrapper is invisible to it
  5. three known types: PathError, LinkError, SyscallError

basics

~20 s

os.IsNotExist predates error wrapping. It peels exactly one layer, and only for the os package's own wrapper types. It never follows an Unwrap chain, so a fmt.Errorf %w wrapper defeats it while errors.Is still matches.

solid answer

~40 s

`os.IsNotExist` was written before Go had error wrapping. Internally it looks at the error's concrete type: if it is a `*fs.PathError`, an `*os.LinkError` or an `*os.SyscallError` it takes that one wrapper's `Err` field, and otherwise it uses the error as given — then it compares that against the not-exist sentinel, asking a `syscall.Errno` via its `Is` method. It never calls `Unwrap` in a loop. So the moment a caller does `fmt.Errorf("load config: %w", err)`, the outer type is none of those three and the predicate returns false, even though the cause is unchanged. `errors.Is` walks the whole chain and still reports true. The same gap applies to `os.IsExist`, `os.IsPermission` and `os.IsTimeout` — treat all four as legacy and use `errors.Is` with `fs.ErrNotExist`, `fs.ErrExist` and `fs.ErrPermission`.

code

go · 8 lines
go
_, err := os.Open(path)
wrapped := fmt.Errorf("load config: %w", err)

// true: errors.Is walks the chain down to the sentinel
modern := errors.Is(wrapped, fs.ErrNotExist)

// false: os.IsNotExist peels one layer, and only for os's own types
legacy := os.IsNotExist(wrapped)

go deeper

for a junior

Know that os.IsNotExist is the old spelling and errors.Is with fs.ErrNotExist is the current one. Be able to say that adding context to an error with %w is what breaks the old predicate.

for a middle

Describe the mechanism: a single type-directed peel over *fs.PathError, *os.LinkError and *os.SyscallError versus a loop over Unwrap. Name the errno-to-sentinel mapping that both share at the bottom.

for a senior

Point out that the failure is silent and directional — the branch just stops running. Argue for removing all four legacy predicates outright rather than leaving them where the error happens not to be wrapped today.

for a principal

Own the migration policy: a mechanical replacement plus a lint rule is cheap, and the alternative is a class of bug that any future error-context change re-introduces. Weigh that against the review cost of re-reading every true branch.

## Two generations of the same check Go shipped `os.IsNotExist`, `os.IsExist`, `os.IsPermission` and `os.IsTimeout` long before it had a general notion of one error containing another. When error wrapping arrived — the `%w` verb for `fmt.Errorf`, the `Unwrap() error` convention, and `errors.Is`/`errors.As` to walk what those produce — the old predicates could not be retrofitted without changing behaviour, so both generations still exist side by side. The documentation now says to prefer `errors.Is`, and understanding *why* is the point of the question. ## What the legacy predicate actually does `os.IsNotExist` performs a single, type-directed peel. In spirit: ```go func underlyingError(err error) error { switch err := err.(type) { case *fs.PathError: return err.Err case *os.LinkError: return err.Err case *os.SyscallError: return err.Err } return err } ``` It then compares that single result against the not-exist sentinel, and if the result is a `syscall.Errno` it asks the errno's own `Is` method. Two properties follow, and both matter: 1. **It knows only three wrapper types.** They are the ones the `os` package itself produces. Any other wrapper — including yours — is invisible to it. 2. **It peels exactly one layer.** Even a chain built entirely from those three types, nested twice, would defeat it. ## What `errors.Is` does differently `errors.Is(err, target)` loops. At each link it tests equality with the target, then asks the current error whether it has an `Is(error) bool` method that wants to claim a match, then calls `Unwrap` to move down. It stops at a match or at the end of the chain. It is agnostic about *how* the link was created: `%w`, a hand-written `Unwrap` method, or one of the `os` wrapper types all look the same to it. So for `fmt.Errorf("load config: %w", os.Open(...) error)` the walk is: the `fmt` wrapper → `*fs.PathError` → `syscall.Errno`, and the errno's `Is` method reports `ENOENT` as `fs.ErrNotExist`. True. `os.IsNotExist` sees only the `fmt` wrapper, which is not one of its three known types, compares that whole value against the sentinel, and returns false. ## Why this is a real bug and not trivia The failure is silent and it is directional. The predicate does not report an error or panic; it quietly answers "no, that is not a missing file". Whatever branch you attached to the *true* case simply stops running. And the change that breaks it is the most innocuous edit in Go: somebody adds context to an error on its way up. A repository layer that used to `return err` now returns `fmt.Errorf("read manifest %s: %w", name, err)` because a log line was hard to read. Every `os.IsNotExist` in every caller flips to false, the "create it if absent" branches stop firing, and nothing in the type system, the compiler or `go vet` mentions it. That is the argument for treating the four legacy predicates as things to remove on sight rather than things to keep working. ## The mapping underneath Both generations share the bottom of the chain: the platform errno decides. `syscall.Errno` implements `Is`, and it reports - `ENOENT` as `fs.ErrNotExist`, - `EACCES` and `EPERM` as `fs.ErrPermission`, - `EEXIST` and `ENOTEMPTY` as `fs.ErrExist`. That mapping is why you should never compare against `syscall.ENOENT` yourself: the errno values differ across operating systems, and the `io/fs` sentinel is the portable name for the condition. It is also why `errors.Is` can match a sentinel the error was never explicitly wrapped in — the errno *claims* the match through its `Is` method rather than being wrapped by it. ## The migration, concretely | legacy | modern | |---|---| | `os.IsNotExist(err)` | `errors.Is(err, fs.ErrNotExist)` | | `os.IsExist(err)` | `errors.Is(err, fs.ErrExist)` | | `os.IsPermission(err)` | `errors.Is(err, fs.ErrPermission)` | The replacements are strictly more capable: everything the old predicate matched, the new one matches too, plus everything reached through a wrapper. There is no case where switching back is correct, so a mechanical replacement across a codebase is safe. The one thing the migration cannot do for you is fix code that put more than absence into the true branch — that is a separate reading of every call site.

  • Which three wrapper types does os.IsNotExist recognise, and why those?
    `*fs.PathError`, `*os.LinkError` and `*os.SyscallError` — the three wrappers the `os` package itself produces. `*fs.PathError` carries `Op` and `Path`, `*os.LinkError` carries `Op`, `Old` and `New` for two-path calls such as `os.Rename`, and `*os.SyscallError` carries the name of a failing system call. The predicate was written to see through exactly what `os` returns and nothing more.
  • Does the same limitation apply to os.IsExist and os.IsPermission?
    Yes — all four legacy predicates, including `os.IsTimeout`, share the same one-layer, three-types implementation. Replace them with `errors.Is(err, fs.ErrExist)` and `errors.Is(err, fs.ErrPermission)`. The replacements match everything the old ones did plus anything reached through a wrapper, so the substitution is mechanical and cannot make a call site worse.
  • Would os.IsNotExist(errors.Unwrap(err)) be an acceptable fix for a wrapped error?
    It happens to work for exactly one layer of wrapping, which makes it worse than useless: it looks correct, and it breaks again the moment somebody adds a second layer or changes where the context is attached. `errors.Is(err, fs.ErrNotExist)` is shorter and depth-independent. Reaching for `errors.Unwrap` by hand is almost always a sign that `errors.Is` or `errors.As` was the intended call.

saying these in an interview costs you the question

  • Claiming os.IsNotExist and errors.Is are interchangeable
  • Thinking os.IsNotExist follows the whole Unwrap chain
  • Believing %w changes the underlying cause
  • Fixing it by calling errors.Unwrap once before the predicate
  • Comparing against syscall.ENOENT to sidestep both