What do the Op and Path fields of *fs.PathError give you, and how do you reach them?
answer
- the message is three fields joined
- logs want fields, not one string
- match by type, not by value
- declare the pointer, pass its address
- the errors helper that assigns into a variable
basics
~20 sOp names the operation that failed ("open", "stat", "mkdir") and Path the name it was given, so a log can carry them as separate structured fields instead of parsing a message. Reach them with errors.As into a *fs.PathError variable.
solid answer
~40 s`*fs.PathError` has three fields: `Op`, the Go-level operation such as `"open"`, `"stat"`, `"mkdir"` or `"remove"`; `Path`, the name exactly as it was passed in, not cleaned or resolved; and `Err`, the underlying cause. Its `Error()` method joins them into `stat /var/lib/app: permission denied`, which is fine for a human but useless to a log index. Getting at the fields is `errors.As(err, &pathErr)` with `pathErr` declared as `*fs.PathError` — `errors.As` walks the same chain `errors.Is` does, but matches by type and assigns into your variable, so it works through `%w` wrappers. Then you can emit `op` and `path` as separate attributes and classify `pathErr.Err` separately. The sibling type is `*os.SyscallError`, with a `Syscall` name and an `Err`, for failures that are not about a named path.
code
go · 8 linesvar pathErr *fs.PathError
if errors.As(err, &pathErr) {
slog.Error("backup step failed",
"op", pathErr.Op,
"path", pathErr.Path,
"cause", pathErr.Err,
)
}go deeper
Recall the three field names — Op, Path and Err — and that the printed message is just those joined together. Know that errors.As is the helper that hands you the struct.
Explain that errors.As matches by concrete type down the same Unwrap chain errors.Is walks, requires a pointer to your variable, and therefore survives %w wrapping where a type assertion would not.
Make the operational case: Op and Path as indexed log attributes let someone triaging a report group failures by operation and path without regexes over platform-specific wording. Know the *os.SyscallError sibling for path-free failures.
Decide what your service's error telemetry contract is: which attributes every failure carries, and whether a raw filesystem path belongs in a log a support engineer will read. Getting that fixed early is cheaper than re-instrumenting later.
## The error as a value, not a string Go's filesystem errors are designed to be read twice: once by a person, from `Error()`, and once by a program, from the fields. `os.Open`, `os.Stat`, `os.Mkdir`, `os.Remove` and their relatives all return `*fs.PathError` (spelled `os.PathError` in the `os` package, which declares it as an alias): ```go type PathError struct { Op string Path string Err error } ``` - **`Op`** is the Go-level operation name, not the system call: `"open"`, `"stat"`, `"mkdir"`, `"remove"`, `"read"`, `"write"`. It tells you *which step* of your program failed even when several steps touch the same path. - **`Path`** is the name as it was handed to the function. It is not made absolute, not cleaned, not symlink-resolved. That is a feature when you are debugging: it shows you the string your code actually constructed, relative segments and all. - **`Err`** is the cause — on Unix usually a `syscall.Errno`. This is what `errors.Is` reaches when you test against `fs.ErrNotExist` or `fs.ErrPermission`. `Error()` formats them as `Op + " " + Path + ": " + Err.Error()`, and `Unwrap()` returns `Err`. ## Getting the value out with `errors.As` `errors.Is` answers a yes/no question about a chain. `errors.As` answers "is there a link in this chain of *this concrete type*, and if so, give it to me": ```go var pathErr *fs.PathError if errors.As(err, &pathErr) { // pathErr.Op, pathErr.Path, pathErr.Err are now available } ``` The second argument must be a **pointer** to the type you want — here a `**fs.PathError`, which is why the `&`. `errors.As` walks the same `Unwrap` chain, so it still finds the path error under a `fmt.Errorf("...: %w", err)` wrapper, and it panics if the target is not a non-nil pointer to a type implementing `error` or to an interface. Its return value tells you whether the assignment happened; the variable is meaningless when it returns false. ## Why this beats reading the message The usual counter-argument is that `Error()` already contains everything. It does — as one string. The difference shows up on the other side of the log pipeline: ```go var pathErr *fs.PathError if errors.As(err, &pathErr) { slog.Error("backup step failed", "op", pathErr.Op, "path", pathErr.Path, "cause", pathErr.Err, ) } ``` Now `op` and `path` are indexed fields. Someone triaging a support report can ask "which paths failed with `op=stat` in the last hour", or count distinct failing directories, without a regular expression over free text whose exact wording is platform-specific. It also gives you a stable key to group on: the same message string differs between operating systems, but `Op` does not. The second use is control flow that needs the *identity* of the thing that failed, not just the class. "Skip this one file and continue the walk, but only if it is under the cache directory" needs `pathErr.Path`. Recovering that by parsing the message would mean splitting on a colon that can also appear inside a filename. ## The sibling: `*os.SyscallError` Not every failure has a path. For those, `os` uses `os.SyscallError`: ```go type SyscallError struct { Syscall string Err error } ``` It is built by `os.NewSyscallError(name, err)`, it formats as `name: cause`, and it unwraps to `Err`. It appears where the operation is named by the system call rather than by a path, and it is common in errors that surface from the `net` package. The same `errors.As` pattern recovers it: ```go var syscallErr *os.SyscallError if errors.As(err, &syscallErr) { // syscallErr.Syscall names the failing call; syscallErr.Err is the cause } ``` A third member of the family is `*os.LinkError`, used by the two-path operations such as `os.Rename` and `os.Link`; it carries `Op`, `Old`, `New` and `Err`, because "which path" is an ambiguous question when there are two. ## When to reach for the fields, and when not to Use `errors.Is` for the decision — is this absence, a denial, a collision — because that is a question about the class of failure and the `io/fs` sentinels are the portable names for those classes. Use `errors.As` when you need data out of the error: the operation and the path for a structured log line, or the syscall name for a metric label. What you should not do is branch on `pathErr.Op == "open"` as a substitute for knowing which call in your own code failed. The `Op` string is documentation-grade, not contract-grade; it is stable in practice but it is not a switch target. Your own wrapping is what should say which step of your program was running — the path error tells you which filesystem primitive gave up, and on what. A recent addition worth knowing: Go 1.26 added `errors.AsType`, a generic form of `errors.As` that returns the extracted error and a boolean instead of taking a pointer argument. It is the same walk with a signature that cannot be misused, and it does not change anything about the types described above.
- Is the Path field cleaned or made absolute before it is stored?No. It is the name exactly as it was passed to the call, with whatever relative segments and unresolved symlinks were in it. That is deliberately useful when debugging, because it shows the string your code actually built rather than a normalised version of it — but it means you cannot compare two path errors for identity by string alone.
- When would you use errors.As rather than errors.Is on a filesystem error?`errors.Is` when you need a yes/no on the class of failure — absent, denied, already there — because the `io/fs` sentinels name those portably. `errors.As` when you need data out of the value: `Op` and `Path` for a structured log line or a metric label, or a `*os.SyscallError`'s `Syscall` name. The two are complementary and a handler often calls both.
- What does errors.As do if you pass it a non-pointer target?It panics. The target must be a non-nil pointer to a type implementing `error` or to an interface type, because `errors.As` has to assign the matching link into it. In practice that means declaring `var pathErr *fs.PathError` and passing `&pathErr`. Its boolean result tells you whether an assignment actually happened; the variable is meaningless when it returns false.
Error() is the printed receipt; the struct fields are the line items. You can read the total off the receipt, but you cannot sort a year of them by category without the line items.
saying these in an interview costs you the question
- Splitting err.Error() on a colon to recover the path
- Type-asserting instead of errors.As, so wrappers defeat it
- Passing the value rather than its address to errors.As
- Assuming Path is absolute or symlink-resolved
- Switching on Op as if it were a stable contract