Introduction

Every Go function that can fail returns an error. You've seen it a thousand times: result, err := doSomething(). And then you write if err != nil and move on. It works. But most Go codebases do just enough to not crash, and nowhere near enough to actually help when things go wrong.

Production errors land in your logs stripped of context. You see connection refused and have no idea which service, which host, which part of the request path. You spend twenty minutes tracing through code that a well-wrapped error would have answered in two seconds.

Go's error model is simple by design. That simplicity is a feature, not a limitation. But simple doesn't mean thoughtless. This article is about doing it right.

Errors are values

In Go, error is just an interface with a single method:

go
type error interface {
    Error() string
}

That's it. Any type that implements Error() string satisfies the interface. This is what Rob Pike means when he says errors are values: they're not special syntax, not exceptions, not control flow magic. They're ordinary values you can inspect, wrap, compare, log, and return.

The most basic way to create one is errors.New:

auth.go
import "errors"

func validateToken(token string) error {
    if token == "" {
        return errors.New("token is required")
    }
    if len(token) < 32 {
        return errors.New("token too short")
    }
    return nil
}

When there's no error, you return nil. The caller checks if err != nil. This pattern repeats everywhere in Go and it is load-bearing. Don't try to shortcut it.

Note

Never ignore errors

Assigning to _ silences the compiler but not production. If a function returns an error, handle it. If you genuinely don't care, that's almost always a bug waiting to happen.

Sentinel errors

Sometimes callers need to know not just that something failed, but what kind of failure it was. The first tool for this is a sentinel error: a package-level variable that represents a specific, named error condition.

store/errors.go
package store

import "errors"

var (
    ErrNotFound      = errors.New("record not found")
    ErrAlreadyExists = errors.New("record already exists")
    ErrUnauthorized  = errors.New("unauthorized")
)
store/users.go
func (s *Store) GetUser(id string) (*User, error) {
    user, ok := s.cache[id]
    if !ok {
        return nil, ErrNotFound
    }
    return user, nil
}

The caller can now branch on the specific error type:

go
user, err := store.GetUser(id)
if err != nil {
    if errors.Is(err, store.ErrNotFound) {
        http.Error(w, "user not found", http.StatusNotFound)
        return
    }
    http.Error(w, "internal error", http.StatusInternalServerError)
    return
}
Sentinel errors are part of your package's public API. Name them well. Export them from a single file. Don't scatter them.

Custom error types

Sentinel errors are strings. When you need to carry structured data alongside the failure — an HTTP status code, a field name, a request ID — you need a custom error type.

Any struct that implements Error() string is an error. You can put whatever you need in the struct.

apierror/error.go
package apierror

import "fmt"

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed on field %q: %s", e.Field, e.Message)
}

type APIError struct {
    Code    int
    Message string
    Err     error
}

func (e *APIError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("[%d] %s: %v", e.Code, e.Message, e.Err)
    }
    return fmt.Sprintf("[%d] %s", e.Code, e.Message)
}

func (e *APIError) Unwrap() error {
    return e.Err
}

Notice the Unwrap() error method on APIError. That single method is what makes Go's error chain work. We'll come back to it.

Returning and handling a custom error type looks like this:

go
func createUser(req CreateUserRequest) (*User, error) {
    if req.Email == "" {
        return nil, &apierror.ValidationError{
            Field:   "email",
            Message: "is required",
        }
    }
    // ...
}

// In the handler:
user, err := createUser(req)
if err != nil {
    var ve *apierror.ValidationError
    if errors.As(err, &ve) {
        // ve.Field and ve.Message are available here
        respondValidationError(w, ve.Field, ve.Message)
        return
    }
    respondInternalError(w, err)
    return
}

Wrapping with context

The most under-used feature in Go error handling is wrapping. When you call a function and it fails, don't just return the raw error. Add context. Tell the next person reading the log what you were trying to do.

Use fmt.Errorf with the %w verb to wrap an error while preserving the original:

service/orders.go
func (s *Service) ProcessOrder(ctx context.Context, orderID string) error {
    order, err := s.store.GetOrder(ctx, orderID)
    if err != nil {
        return fmt.Errorf("fetching order %s: %w", orderID, err)
    }

    if err := s.inventory.Reserve(ctx, order.Items); err != nil {
        return fmt.Errorf("reserving inventory for order %s: %w", orderID, err)
    }

    if err := s.payments.Charge(ctx, order); err != nil {
        return fmt.Errorf("charging payment for order %s: %w", orderID, err)
    }

    return nil
}

Now when this fails in production, your log reads:

charging payment for order ord_8f2k1: [503] upstream timeout: connection refused

Instead of just: connection refused

The wrapping chain tells the whole story. Each layer adds its context without destroying what came before. This is the single highest-leverage change most Go services can make to their error handling.

⚠ Gotcha

Don't wrap at every layer blindly

Wrapping adds value when it adds context. If you're just re-wrapping with the same message the inner function already gives, you're adding noise. Wrap when you can add something the inner error doesn't know: the ID you were looking up, the operation you were trying to perform, the user that triggered it.

errors.Is and errors.As

Once errors are wrapped, equality checks with == break. wrappedErr == store.ErrNotFound will be false because the wrapped error is a different value. That's what errors.Is and errors.As are for.

errors.Is walks the error chain and checks whether any error in the chain matches the target:

go
err := fmt.Errorf("processing order: %w", store.ErrNotFound)

// This is false — different pointer
err == store.ErrNotFound

// This is true — errors.Is walks the chain
errors.Is(err, store.ErrNotFound)

errors.As does the same but for types. It walks the chain and tries to assign to the target pointer. Use it when you need the actual error struct, not just a match:

go
err := fmt.Errorf("creating user: %w", &apierror.ValidationError{
    Field:   "email",
    Message: "already taken",
})

var ve *apierror.ValidationError
if errors.As(err, &ve) {
    // ve is now the unwrapped *ValidationError
    fmt.Println(ve.Field)   // "email"
    fmt.Println(ve.Message) // "already taken"
}

✦ Tip

Use errors.Is for sentinel errors, errors.As for structured types

errors.Is is for comparing identity (is this the ErrNotFound I defined?). errors.As is for extracting data (give me the ValidationError so I can read its fields). Know which one you need before you write the check.

Panic and recover

Panic is not error handling. It's for truly unrecoverable states: nil pointer dereferences, out-of-bounds accesses, invariant violations that should never happen if the code is correct. You should almost never call panic explicitly in application code.

That said, HTTP servers need to survive panics in individual handlers without taking down the whole process. That's where recover belongs: at the top of a goroutine, in a deferred function, converting a panic into a 500 response and a logged stack trace.

middleware/recover.go
func RecoverMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                buf := make([]byte, 4096)
                n := runtime.Stack(buf, false)
                log.Printf("panic recovered: %v
%s", rec, buf[:n])
                http.Error(w, "internal server error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

Apply this middleware once, at the outermost layer. Everywhere else in your application, return errors. Reserve panic for the category of bugs that mean your program has entered an invalid state and continuing would make things worse.

Patterns worth keeping

Constructor functions for errors

For errors you create frequently, a constructor function is cleaner than inline struct literals and makes refactoring easier:

apierror/constructors.go
func NotFound(resource, id string) error {
    return &APIError{
        Code:    404,
        Message: fmt.Sprintf("%s %q not found", resource, id),
    }
}

func Unauthorized(reason string) error {
    return &APIError{
        Code:    401,
        Message: reason,
    }
}

func Internal(op string, err error) error {
    return &APIError{
        Code:    500,
        Message: op,
        Err:     err,
    }
}

Centralised error handling in HTTP

Instead of writing response logic in every handler, have your handlers return errors and let a single function translate them into HTTP responses. This keeps handlers focused on business logic and puts all the HTTP/error mapping in one place.

handler/handler.go
type HandlerFunc func(w http.ResponseWriter, r *http.Request) error

func Wrap(h HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        if err := h(w, r); err != nil {
            handleError(w, err)
        }
    }
}

func handleError(w http.ResponseWriter, err error) {
    var apiErr *apierror.APIError
    if errors.As(err, &apiErr) {
        respond(w, apiErr.Code, map[string]string{"error": apiErr.Message})
        return
    }

    var ve *apierror.ValidationError
    if errors.As(err, &ve) {
        respond(w, http.StatusBadRequest, map[string]string{
            "error": "validation failed",
            "field": ve.Field,
        })
        return
    }

    log.Printf("unhandled error: %v", err)
    respond(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
handler/users.go
// Handlers just return errors — no response logic scattered everywhere
func GetUser(w http.ResponseWriter, r *http.Request) error {
    id := chi.URLParam(r, "id")

    user, err := userStore.GetUser(r.Context(), id)
    if err != nil {
        return fmt.Errorf("get user handler: %w", err)
    }

    return respondJSON(w, http.StatusOK, user)
}

// Wire it up
mux.Get("/users/{id}", handler.Wrap(GetUser))

✦ Tip

This pattern scales well

Adding a new error type means updating handleError in one place, not hunting through 40 handlers. Logging, tracing, and metrics all live in one function. The handlers stay clean.

Log once, at the top

A very common mistake is logging the error at every layer that touches it. You end up with four log lines for a single failure, each with different context, and it looks like four separate problems. The rule is simple: log at the boundary where the error is handled and no longer propagated. Everywhere below that point, wrap and return.

go
// Bad — logs at every level
func (s *Service) GetOrder(ctx context.Context, id string) (*Order, error) {
    order, err := s.store.GetOrder(ctx, id)
    if err != nil {
        log.Printf("store.GetOrder failed: %v", err) // don't log here
        return nil, fmt.Errorf("getting order: %w", err)
    }
    return order, nil
}

// Good — wrap and return, log once at the HTTP handler
func (s *Service) GetOrder(ctx context.Context, id string) (*Order, error) {
    order, err := s.store.GetOrder(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("getting order %s: %w", id, err)
    }
    return order, nil
}

Summary

Go's error handling is verbose by design. That verbosity is a forcing function: it makes you think about failure at every step. The goal isn't to hide that verbosity. The goal is to make every error check count.

  • Use errors.New for simple, static error messages.
  • Use sentinel errors (var ErrX = errors.New(...)) when callers need to branch on specific failures.
  • Use custom error types when you need to carry structured data with a failure.
  • Always implement Unwrap() error on custom types that wrap another error.
  • Wrap errors with fmt.Errorf("context: %w", err) at every layer — add what the current layer knows that the inner error doesn't.
  • Use errors.Is to match sentinel errors through a chain.
  • Use errors.As to extract a typed error from anywhere in the chain.
  • Use recover only at goroutine boundaries, never as a substitute for returning errors.
  • Log errors once, at the top where they're handled. Wrap everywhere else.