Given an error from os.Open, how do you check that the file does not exist?
answer
- the returned error is not the sentinel
- os.Open hands back a wrapper value
- you need a helper that unwraps
- errors package, two-argument comparison
- the io/fs sentinel, not the raw errno
basics
~10 sUse errors.Is(err, fs.ErrNotExist). os.Open returns a *fs.PathError wrapping the operating system's error, so comparing the returned error directly against fs.ErrNotExist is always false; errors.Is unwraps the chain and finds the sentinel.
solid answer
~40 sCall `errors.Is(err, fs.ErrNotExist)`. The value `os.Open` hands back is not the sentinel itself — it is a `*fs.PathError` holding `Op` ("open"), `Path` (the name you passed) and `Err` (typically a `syscall.Errno` such as `ENOENT`). A direct `err == fs.ErrNotExist` therefore never matches, and matching on the message text is worse. `errors.Is` walks the chain through `Unwrap`, and at the bottom `syscall.Errno` has an `Is` method that reports `ENOENT` as `fs.ErrNotExist`. The same family covers the neighbouring cases: `fs.ErrExist` for a create that collided, `fs.ErrPermission` for a denial. `os.ErrNotExist` is declared as `fs.ErrNotExist`, so either name works — but only for absence: every other error still has to be handled.
code
go · 10 linesf, err := os.Open(path)
if errors.Is(err, fs.ErrNotExist) {
// no config file yet: fall back to built-in defaults
return defaultConfig(), nil
}
if err != nil {
// a denial, a bad mount, a name that is not a directory
return Config{}, err
}
return parseConfig(f)go deeper
Memorise the one-liner: errors.Is(err, fs.ErrNotExist). Be ready to say why a plain equality check against the sentinel never matches, and to name the two neighbours, fs.ErrExist and fs.ErrPermission.
Explain the walk: os.Open returns a *fs.PathError, errors.Is follows Unwrap down to a syscall.Errno, and that errno's own Is method reports ENOENT as fs.ErrNotExist. Say why this still works after you wrap with %w.
Show that only fs.ErrNotExist may take the absence branch and everything else must fail loudly. Argue for asking for the file with O_CREATE|O_EXCL rather than testing existence first and acting on the answer.
Frame this as an API contract: the moment your package documents that it returns something matching fs.ErrNotExist, callers will branch on it and you cannot stop wrapping it. Decide deliberately which stdlib sentinels your package promises to pass through.
## What `os.Open` actually returns Go's filesystem calls do not report failure with a bare sentinel value. They return a **structured error**. When `os.Open("/etc/app.conf")` fails because the file is not there, the error's dynamic type is `*fs.PathError` (the `os` package declares `os.PathError` as an alias for the `io/fs` type, so the two names mean the same thing): ```go type PathError struct { Op string // "open", "stat", "mkdir", "remove", ... Path string // the name exactly as it was passed in Err error // the underlying cause, usually a syscall.Errno } ``` Its `Error()` method formats as `open /etc/app.conf: no such file or directory`, which is why the printed message reads so well. It also has `Unwrap() error`, which returns the `Err` field. ## Why the direct comparison fails Because the returned value is a `*fs.PathError`, this is always false: ```go if err == fs.ErrNotExist { // never true for an error from os.Open ``` The sentinel `fs.ErrNotExist` is one specific error value created inside `io/fs`. The value you were handed is a different value that *contains* the cause. Equality compares the outer wrapper, not the cause. ## What `errors.Is` does instead `errors.Is(err, target)` walks a chain. At each step it compares the current error to the target for equality; if that fails, it asks the current error whether it has an `Is(error) bool` method and lets it decide; then it moves to the next link by calling `Unwrap`. It stops when something matches or the chain runs out. For a missing file the walk goes: `*fs.PathError` (not equal, no match) → `Unwrap` → `syscall.Errno` holding `ENOENT`. `syscall.Errno` implements `Is`, and that method reports true for `fs.ErrNotExist` when the number is `ENOENT`. So `errors.Is(err, fs.ErrNotExist)` returns true. That design is what makes the check survive your own wrapping. If a helper returns `fmt.Errorf("load config: %w", err)`, the `%w` verb adds another `Unwrap` link and `errors.Is` simply walks one step further. ## The rest of the family `io/fs` defines a small set of portable sentinels that the operating-system layer maps onto: - `fs.ErrNotExist` — the file or directory is not there (`ENOENT`). - `fs.ErrExist` — it is already there, typically from `os.OpenFile` with `os.O_CREATE|os.O_EXCL`, or `os.Mkdir` on an existing directory (`EEXIST`, and `ENOTEMPTY`). - `fs.ErrPermission` — the process is not allowed to do this (`EACCES`, `EPERM`). - `fs.ErrClosed` — the file handle has already been closed. - `fs.ErrInvalid` — an invalid argument, such as an empty name. `os.ErrNotExist`, `os.ErrExist`, `os.ErrPermission` and friends are declared as those same values, so `errors.Is(err, os.ErrNotExist)` and `errors.Is(err, fs.ErrNotExist)` are the identical test. Prefer the `io/fs` names in new code: they also apply to errors from `fs.FS` implementations that never touch the operating system, such as an embedded or in-memory filesystem. These sentinels are the portable layer. Underneath, the concrete number differs per platform, which is exactly why you should not reach for `syscall.ENOENT` yourself — the same Go program compiled for a different operating system reports a different value, and `errors.Is` against the `io/fs` sentinel is the abstraction that survives the move. ## Absence is a branch, not a catch-all The important discipline in the code around the check is that only `fs.ErrNotExist` means "not there". An error that is not `nil` and not a not-exist error is a real failure — a denial, a broken mount, a name that is not a directory — and it must not fall into the "treat it as missing" branch: ```go f, err := os.Open(path) switch { case err == nil: // use f case errors.Is(err, fs.ErrNotExist): // genuinely absent: create it, or fall back to a default default: return err // anything else is a failure, not an absence } ``` A final note on style: when the next thing you do is open or create the file anyway, do not test for existence first and then act. Ask for what you want and read the error — `os.OpenFile` with `os.O_CREATE|os.O_EXCL` either succeeds or fails with an error matching `fs.ErrExist`, in one call, with no window between the check and the act.
- What is the dynamic type of that error, and what does it hold?It is a `*fs.PathError` (also spelled `os.PathError`), with `Op` set to `"open"`, `Path` set to the name you passed, and `Err` holding the underlying cause — on Unix a `syscall.Errno` of `ENOENT`. Its `Error()` method joins the three into `open /etc/app.conf: no such file or directory`, and its `Unwrap` method returns `Err`.
- How would you check the opposite condition — a create that failed because the file is already there?`errors.Is(err, fs.ErrExist)`, after `os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)`. That combination makes the existence test and the creation a single operation, so there is no gap between deciding the file is absent and creating it. `os.Create` will not do: it truncates an existing file instead of failing.
- Is os.ErrNotExist a different value from fs.ErrNotExist?No. The `os` package declares `ErrNotExist` as `fs.ErrNotExist`, so they are the same error value and `errors.Is` against either behaves identically. The same holds for `ErrExist`, `ErrPermission`, `ErrClosed` and `ErrInvalid`. New code usually names the `io/fs` ones, because they also apply to errors from `fs.FS` implementations that never touch the operating system.
The sentinel is the diagnosis; the returned error is the whole chart, with the operation and the path stapled to the front. You have to read past the cover sheet to find the diagnosis.
saying these in an interview costs you the question
- Writing err == fs.ErrNotExist and expecting it to match
- Matching on err.Error() containing "no such file"
- Treating any non-nil error from os.Open as absence
- Comparing against syscall.ENOENT directly instead of the io/fs sentinel
- Calling os.Stat first, then opening, and ignoring the gap