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.

Laptop glowing in a dark room with code on screen during a debugging session

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

  1. Check the error before touching the result.

  2. Add context with %w at each layer that knows something new.

  3. Define sentinels for failures callers must branch on.

  4. Use custom types when callers need fields, not strings.

  5. Compare with errors.Is / errors.As — never err.Error() == "...".

  6. 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.