Skip to main content

Error Handling in Go

Published: December 17, 2025 Updated: August 29, 2026 Larry Qu 8 min read

Go’s approach to error handling is a deliberate design choice: functions return errors as regular values, and callers handle them explicitly. There’s no try/catch, no exception hierarchy, no stack unwinding. If a function can fail, it returns (result, error), and every call site decides what to do.

This makes error paths visible in code. You can’t accidentally ignore an exception — the Go compiler warns when you discard a multi-return value, and the convention of checking if err != nil immediately after every call is so universal that it shapes how Go code is read and reviewed.

For related topics see Go custom errors, Go defer and panic, and Go best practices.

The error Interface

error is a built-in interface with a single method:

type error interface {
    Error() string
}

Any type that implements Error() string is an error. This simplicity means you can attach any data you want to an error — HTTP status codes, field names, retry hints — by creating a struct that implements the interface.

The standard way to create a simple error is errors.New or fmt.Errorf:

import "errors"

var ErrNotFound = errors.New("not found")

func findUser(id int) (*User, error) {
    if id <= 0 {
        return nil, fmt.Errorf("invalid user ID %d: must be positive", id)
    }
    // ...
}

Checking Errors

The canonical Go idiom: call the function, check the error immediately, handle or return it:

data, err := os.ReadFile("config.json")
if err != nil {
    return fmt.Errorf("loading config: %w", err)
}

The %w verb in fmt.Errorf wraps the original error — the returned error includes the context message but the original is still accessible for inspection. This is the foundation of Go’s error chain model.

Two rules for clean error checking:

Return early, don’t nest. Each error check should return or handle immediately. Deep if err == nil { ... } nesting is a sign the code should be restructured.

Add context at each layer. When wrapping an error, the message should describe what the current function was trying to do, not repeat what the inner error already says:

// ❌ Redundant — the inner error already says "permission denied"
return fmt.Errorf("error: permission denied: %w", err)

// ✅ Adds context about what was attempted
return fmt.Errorf("opening audit log %s: %w", path, err)

After several wrapping layers, err.Error() produces a chain like:
"starting server: loading config: opening audit log /etc/app.conf: permission denied"
Each layer describes one step of what was happening, readable from outer to inner.

Custom Error Types

When callers need to inspect the details of a failure — not just the message — define a custom type:

type ValidationError struct {
    Field   string
    Message string
}

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

func validateAge(age int) error {
    if age < 0 {
        return &ValidationError{Field: "age", Message: "must be non-negative"}
    }
    if age > 150 {
        return &ValidationError{Field: "age", Message: "unreasonably large"}
    }
    return nil
}

Callers that only care about the message treat it like any error. Callers that need the field name use errors.As to extract the typed value:

err := validateAge(-5)
var ve *ValidationError
if errors.As(err, &ve) {
    fmt.Printf("fix the %q field: %s\n", ve.Field, ve.Message) // fix the "age" field: must be non-negative
}

errors.As traverses the error chain — it finds the first error in the chain that can be assigned to the target type, whether it’s the error itself or wrapped inside other errors.

Sentinel Errors

A sentinel error is a package-level variable used for comparison. Use them when callers need to distinguish specific failure modes:

var (
    ErrNotFound   = errors.New("not found")
    ErrPermission = errors.New("permission denied")
    ErrConflict   = errors.New("resource conflict")
)

func getItem(id string) (*Item, error) {
    if id == "" {
        return nil, ErrNotFound
    }
    // ...
}

Callers use errors.Is to check for a sentinel, even through a chain of wrapping:

item, err := getItem(id)
if errors.Is(err, ErrNotFound) {
    http.Error(w, "item not found", http.StatusNotFound)
    return
}
if err != nil {
    http.Error(w, "internal error", http.StatusInternalServerError)
    return
}

errors.Is walks the chain by calling Unwrap() on each error until it finds a match or exhausts the chain. This means you can wrap ErrNotFound with context and errors.Is still finds it:

err := fmt.Errorf("fetching order %s: %w", orderID, ErrNotFound)
fmt.Println(errors.Is(err, ErrNotFound)) // true

errors.Is vs errors.As

Function Use when Example
errors.Is(err, target) Checking for a specific error value or sentinel errors.Is(err, io.EOF)
errors.As(err, &target) Extracting a specific error type for its fields errors.As(err, &ve) where ve is *ValidationError

Never compare errors with == directly — it only works for the exact error value and breaks as soon as the error is wrapped. Always use errors.Is and errors.As.

Wrapping and Unwrapping

fmt.Errorf with %w wraps an error. The errors.Unwrap function extracts the inner error one level at a time. errors.Is and errors.As use Unwrap automatically.

When you want to return an error that signals multiple conditions (e.g., the request is invalid AND the token is expired), Go 1.20 added errors.Join:

var errs []error
if name == "" {
    errs = append(errs, ErrMissingName)
}
if email == "" {
    errs = append(errs, ErrMissingEmail)
}
if len(errs) > 0 {
    return errors.Join(errs...)
}

errors.Is checks all joined errors, so callers can test for each condition independently.

Handling Errors in HTTP Handlers

A clean pattern in web services: define an AppError that carries an HTTP status code alongside the internal error, and handle it centrally in middleware:

type AppError struct {
    StatusCode int
    Message    string
    Err        error
}

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

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

// Convenience constructors
func NotFound(msg string) *AppError {
    return &AppError{StatusCode: http.StatusNotFound, Message: msg}
}

func Internal(err error) *AppError {
    return &AppError{StatusCode: http.StatusInternalServerError, Message: "internal error", Err: err}
}

Handler functions return error, and a wrapping handler converts the error to an HTTP response:

type handlerFunc func(w http.ResponseWriter, r *http.Request) error

func handle(h handlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        if err := h(w, r); err != nil {
            var appErr *AppError
            if errors.As(err, &appErr) {
                http.Error(w, appErr.Message, appErr.StatusCode)
            } else {
                http.Error(w, "internal server error", http.StatusInternalServerError)
                log.Printf("unhandled error: %v", err)
            }
        }
    }
}

// Usage
mux.Handle("/users/{id}", handle(func(w http.ResponseWriter, r *http.Request) error {
    user, err := db.GetUser(r.PathValue("id"))
    if err != nil {
        return Internal(err)
    }
    if user == nil {
        return NotFound("user not found")
    }
    json.NewEncoder(w).Encode(user)
    return nil
}))

This keeps error handling logic in one place and keeps handler functions focused on the happy path.

When to Use panic

panic is not for regular error handling. Use it only for programmer errors — situations that should never happen in correct code:

func mustParseURL(raw string) *url.URL {
    u, err := url.Parse(raw)
    if err != nil {
        panic(fmt.Sprintf("invalid hardcoded URL %q: %v", raw, err))
    }
    return u
}

var baseURL = mustParseURL("https://api.example.com") // panics at startup if wrong

The Must naming convention (from regexp.MustCompile, template.Must) signals a function that panics on error and is only appropriate for values known at compile time or program startup.

recover belongs in deferred functions at the top of call stacks (e.g., an HTTP handler or a goroutine entry point) to prevent one panicking goroutine from crashing the entire process:

func safeHandler(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                log.Printf("panic recovered: %v\n%s", rec, debug.Stack())
                http.Error(w, "internal server error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

Common Mistakes

Swallowing errors with _. Discarding an error with _, err = ... or _ = f() is occasionally legitimate (closing a response body) but should be rare and commented. Silently ignoring errors is how subtle bugs survive code review.

Re-wrapping the same message. If openDB already returns "failed to connect to postgres: ...", wrapping it as "database error: failed to connect to postgres: ..." adds noise. Wrap with what your function was doing, not a restatement of the inner error.

Using string comparison on errors. err.Error() == "not found" breaks the moment the error is wrapped. Use errors.Is.

Returning error from every function unconditionally. Some functions genuinely cannot fail — a function that formats a string or sorts a slice doesn’t need to return an error. Reserve error returns for operations that interact with the outside world (files, network, databases) or perform validation with multiple failure modes.

Summary

  • The error interface is just Error() string — implement it on any struct to attach structured data to failures
  • Wrap errors with fmt.Errorf("doing X: %w", err) to build readable call chains
  • Use errors.Is for sentinel comparison, errors.As for type extraction — never == or type assertions directly
  • Sentinel errors (var ErrNotFound = errors.New(...)) let callers branch on specific failure modes without parsing strings
  • panic is for programmer errors and should be recovered at service boundaries, not used as regular control flow

Resources

Comments

👍 Was this article helpful?