Go treats errors as values. A function that can fail returns an error as its last result, and the caller checks it explicitly. Go has no exception mechanism that carries failures up the call stack. Each layer of your program decides what to do with an error: add information, pass it on unchanged, or handle it. "Context" here means the information you attach to an error as it travels upward, such as which operation failed and with what input. It is separate from the context.Context type that carries cancellation signals, deadlines, and request-scoped values.
Since Go 1.13, the standard library supports error wrapping. fmt.Errorf with the %w verb creates a new error that keeps the original error attached underneath, so callers can still find it. Two functions make that possible. errors.Is checks whether a specific sentinel error appears anywhere in the chain of wrapped errors. errors.As extracts an error of a particular type from the chain. A sentinel error is a package-level variable that represents one specific, expected failure, such as io.EOF. The main decisions in error handling are when to wrap, when to return the error directly, and how to preserve the errors callers need to recognize.
What wrapping does to an error
Go’s error interface is minimal: any type satisfies it by providing an Error() string method. The Go documentation explains that errors.New returns a new error value on every call, even when the message text is identical, so you cannot compare error values directly.
fmt.Errorf with %w is the standard way to wrap an error with context:
func loadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config %s: %w", path, err)
}
// ...
}
The Go error values FAQ notes that switching from %v to %w leaves the error string unchanged while wrapping the original error, so callers can reach it with errors.Unwrap, errors.Is, or errors.As. If you use %v instead, you flatten the original error into the message and discard it, so downstream code can no longer detect the sentinel or inspect the underlying type.
When to wrap and when to return the error directly
Wrap an error where the failure happens, and only when you add information the eventual handler will use. "read config %s" tells whoever reads the log which file failed and which operation was in progress. That is the value of wrapping. Go error handling guidance for scaling codebases puts it as a rule: wrap at the point of origin, never in the middle without adding information. A function that calls another function and can only produce "operation failed: operation failed" adds noise, and wrapping the same call ten times inside a loop adds ten identical layers.
Return the error unchanged when you cannot add anything helpful. A guide to popular error handling techniques makes the same point: a function should pass the error on unchanged if it cannot add helpful information. The failure mode to avoid is the older habit of building a new error from a string with errors.New("open failed: " + err.Error()). That keeps only the message text and throws away the original error value, so errors.Is and errors.As can never see it. If you want to add a message, %w does it without losing the chain.
The Go error values FAQ gives one sentinel a special rule: io.EOF should never be wrapped. Callers compare against io.EOF directly, and if you wrap it, existing == checks stop matching. Return it as-is.
Preserving sentinel errors with errors.Is
A sentinel error is a package-level variable that names one specific, expected failure. io.EOF, sql.ErrNoRows, and your own var ErrNotFound = errors.New("not found") are sentinels. Callers check for them with errors.Is, which unwraps the chain one error at a time until it finds a match or runs out of errors.
var ErrNotFound = errors.New("user not found")
func FindUser(id string) (*User, error) {
user, ok := users[id]
if !ok {
return nil, fmt.Errorf("find user %q: %w", id, ErrNotFound)
}
return &user, nil
}
The caller checks:
user, err := FindUser(id)
if errors.Is(err, ErrNotFound) {
// return a 404 to the client
}
Never use == on an error that may have been wrapped. The wrapped error is a new value, so the comparison always returns false, and Go error handling guidance calls this the most common mistake it sees. errors.Is handles both the direct and the wrapped case, so you can use it everywhere.
Exporting a sentinel makes it part of your public API. Callers import your package to reference ErrNotFound, so removing or renaming it breaks them. Guidance on wrapping and custom types treats every exported error as a commitment you must maintain. A sentinel also creates a source-level dependency: code that checks for io.EOF must import io. That is fine for stable, widely used sentinels, but it is a real cost before you export one for every failure mode.
Preserving typed errors with errors.As
Some failures need to carry structured data, not just an identity. A validation error might expose which field failed; a network error might expose the address. For those, define a custom error type and use errors.As to extract it.
type ValidationError struct {
Field string
Msg string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("invalid %s: %s", e.Field, e.Msg)
}
errors.As finds the first error that matches the target type as it walks the chain, then assigns it to the target variable:
var verr *ValidationError
if errors.As(err, &verr) {
log.Printf("field %s: %s", verr.Field, verr.Msg)
}
If your custom type wraps another error, give it an Unwrap() error method so errors.Is and errors.As can see through it. Custom error types with stack traces rely on that method; without it, both functions stop at the custom type and never reach the error inside.
A typed error is worth it only when at least one caller needs to read a field and branch on its value. Guidance on wrapping and custom types puts it as a decision rule: if callers only need to know which failure happened, a sentinel plus a wrapping message is enough, and introducing a struct for every failure mode is an anti-pattern. The Go error values FAQ offers an intermediate option: put the details in the error message with %v or %s and skip the Unwrap method. People reading logs see the detail, but client code cannot depend on the nested error type. That keeps the error opaque and leaves you free to change the implementation later.
What your API exposes when you wrap
Every error you return becomes part of your API. When you wrap an error from a library, callers can reach into the chain with errors.Is and errors.As and depend on the library’s error types. That dependency constrains you: if a caller starts checking for a database driver’s error and you later switch drivers, that caller breaks. The Go error values FAQ warns that wrapping exposes implementation detail, and every error you expose constrains the evolution of your code.
Dave Cheney, in his guide to handling errors gracefully, argues for avoiding sentinel errors and custom error types in public APIs and returning opaque errors instead. An opaque error is one whose internals the caller cannot inspect; the caller knows the operation failed but cannot couple to the specific failure. This requires the least coupling between your code and its callers, at the cost of less information for callers that genuinely need it.
Two rules keep the boundary clean. Never inspect the output of the Error() method; Cheney’s guide calls comparing the string form of an error a code smell, because the string exists for humans and logs, not for code. At an API boundary such as an HTTP handler, sanitize the error: log the full internal error with its chain, and return a generic message to the client so internal details, file paths, and library internals do not leak. Go error handling guidance recommends this separation between what gets logged and what gets returned.
Keeping error chains readable
Each %w call adds a layer to the message string but no stack trace. Go does not capture the call stack when an error is created, so a long chain gives you a readable message without line numbers. A practical guide to error handling in Go notes that compensating for this requires application tracing, which is harder in Go than in languages whose runtimes provide automatic instrumentation. If you want stack traces attached to errors, the github.com/pkg/errors package captures them at creation time and prints them with fmt.Printf("%+v\n", err), though the package is in maintenance mode since the standard library added wrapping. Custom error types with stack traces show the same pattern for your own types.
Handle an error only once. Handling means inspecting the error and making a decision. Making two decisions for one error causes problems. The classic example is logging an error and returning it: the log line appears in the middle of the call stack, then the top-level handler logs the same error again, and the original context is lost by the time it reaches the top. Cheney’s guide describes this as producing duplicate log lines and losing context at the top of the program. One layer, usually the outermost, logs; the layers below wrap or return.
When several independent operations can fail, such as closing multiple resources during cleanup, errors.Join combines them into a single error. The Go blog post on wrapping multiple errors in Go 1.20 introduced it for exactly this case:
func closeAll(closers ...io.Closer) error {
var errs []error
for _, c := range closers {
if err := c.Close(); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
A joined error holds a slice of errors, and errors.Is and errors.As check each one, so callers can still detect a specific failure inside the group. The same Go blog post explains that the joined error type provides an Unwrap() []error method that returns the slice.