Go has no exceptions. Functions that can fail return an error value alongside their result, and the caller decides what to do with it. Newcomers often read this as boilerplate; experienced Go developers read it as the control flow itself. This guide covers the patterns that matter in real code: checking, sentinel errors, wrapping, inspection with errors.Is and errors.As, custom types, and the line between errors and panics.
The core idea: errors are values
error is an interface with a single method, Error() string. A function signals failure by returning a non-nil error; success is a nil error. The handling pattern is always the same shape:
data, err := os.ReadFile("config.json")
if err != nil {
return err // or handle it here
}
// data is safe to use
Three rules make this pattern work: check every error immediately, never use a result whose error is non-nil, and handle each error exactly once — either act on it or return it, never both (logging and returning is the classic double-handling bug).
Sentinel errors: named failure cases
When callers need to react to a specific failure, export a package-level sentinel value and return it directly:
var ErrNotFound = errors.New("record not found")
func FindUser(id int) (User, error) {
user, ok := store[id]
if !ok {
return User{}, ErrNotFound
}
return user, nil
}
The standard library does this everywhere — io.EOF and sql.ErrNoRows are sentinels. Callers test for them with errors.Is(err, ErrNotFound), never with string comparison.
Wrapping: add context without losing the cause
Returning a bare error up five layers loses the story of what failed. Wrap it with fmt.Errorf and the %w verb to attach context while preserving the original:
func LoadUser(id int) error {
_, err := FindUser(id)
if err != nil {
return fmt.Errorf("load user %d: %w", id, err)
}
return nil
}
The resulting message reads like a breadcrumb trail — load user 42: record not found — and the chain underneath stays machine-readable. Use %v instead of %w when you deliberately want to cut the chain, for example at an API boundary where internals should not leak.
Inspecting errors: errors.Is and errors.As
errors.Is walks the wrap chain looking for a sentinel. errors.As walks it looking for a concrete type, which is how you extract structured details:
type ValidationError struct {
Field string
Issue string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Issue)
}
// at the call site:
var verr *ValidationError
if errors.As(err, &verr) {
// respond with verr.Field and verr.Issue
}
Custom error types shine when the caller needs data, not just a message — an HTTP status code, a field name, a retry delay.
Errors vs panics
Panics are for unrecoverable programmer errors: nil-map writes in initialisation, impossible states, broken invariants. Anything a user, a network or a file can cause is an error, not a panic. Libraries should almost never panic across their public API. Two habits keep this clean:
-
Fail fast at boundaries. Validate input where it enters (such as in an HTTP handler — see our complete walkthrough on building a REST API with Go and Fiber) and convert messy external failures into your own error types there.
-
Return, don’t log, in libraries. Let the top of the program decide how an error is reported. A library that logs and returns forces duplicate output. (When coordinating failures across concurrent routines, channels are the standard approach — see Goroutines and Channels explained simply).
A compact checklist
-
Check the error before touching the result.
-
Add context with
%wat each layer that knows something new. -
Define sentinels for failures callers must branch on.
-
Use custom types when callers need fields, not strings.
-
Compare with
errors.Is/errors.As— nevererr.Error() == "...". -
Handle each error once. Panic only on bugs, not on bad input.
Good Go error handling is just good storytelling: every layer adds one honest line about what it was doing, and the reader of the log can reconstruct the whole failure without a debugger.



