Skip to content

Go error handling patterns that actually help

Go error handling patterns that actually help

I wrote my first Go service about three years ago and my main takeaway was: I have never typed if err != nil so many times in my life. It felt like half the codebase was error checks. I nearly went back to Python out of sheer frustration.

Three years later, I write Go full time and I still type if err != nil constantly. The difference is I stopped fighting it and started building patterns around it that make the repetition less painful and the errors themselves more useful. This post is those patterns.

The problem isn’t the syntax, it’s the context

Most complaints about Go error handling focus on the verbosity. Fair enough. But the real issue I kept hitting wasn’t the number of lines, it was that my errors were useless when they reached the top of the call stack. Something like open config.json: no such file or directory is fine. But invalid input with no indication of which function, which layer, or which input? That’s the actual problem.

Go’s error handling is verbose by design because it forces you to make a decision at every call site. The question is whether you make a good decision or just pass the error along unchanged. Most tutorials teach you to do the latter, and that’s where things go wrong.

Error wrapping with %w changed everything

Go error handling patterns that actually help

Before Go 1.13, you had two bad options: return the error as-is (losing context) or format a new error string with fmt.Errorf (losing the original error). The %w verb fixed this by letting you add context while preserving the original:

// Bad: caller has no idea where this came from
func LoadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, err
    }
    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, err
    }
    return &cfg, nil
}
// Better: every error carries its origin
func LoadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("load config: read %s: %w", path, err)
    }
    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("load config: parse json: %w", err)
    }
    return &cfg, nil
}

Now when this fails, you get load config: read /etc/myapp/config.json: permission denied instead of just permission denied. The caller can still use errors.Is and errors.As to check the underlying cause. You get context and type safety.

The pattern I settled on: prefix with the function name and a short description of what step failed. Keep it lowercase, no punctuation at the end. It reads naturally when errors chain: start server: load config: read /etc/myapp/config.json: permission denied.

Sentinel errors for things callers need to branch on

Sometimes the caller needs to do different things depending on what went wrong. That’s where sentinel errors and errors.Is come in:

var (
    ErrNotFound     = errors.New("not found")
    ErrUnauthorized = errors.New("unauthorized")
    ErrRateLimited  = errors.New("rate limited")
)

func GetUser(id string) (*User, error) {
    row := db.QueryRow("SELECT ... WHERE id = $1", id)
    var u User
    if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, fmt.Errorf("get user %s: %w", id, ErrNotFound)
        }
        return nil, fmt.Errorf("get user %s: %w", id, err)
    }
    return &u, nil
}

Now the HTTP handler can check errors.Is(err, ErrNotFound) and return a 404 without knowing anything about SQL. The database is an implementation detail that doesn’t leak through the API boundary.

I define sentinels at the package level, one file called errors.go. Three to five per package is normal. If you have fifteen, your package is probably doing too much.

Custom error types when you need structured data

Sentinels work for simple branching. When the caller needs details about what went wrong, use a custom error type:

type ValidationError struct {
    Field   string
    Message string
}

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

func ParseAge(input string) (int, error) {
    age, err := strconv.Atoi(input)
    if err != nil {
        return 0, &ValidationError{
            Field:   "age",
            Message: fmt.Sprintf("%q is not a number", input),
        }
    }
    if age < 0 || age > 150 {
        return 0, &ValidationError{
            Field:   "age",
            Message: fmt.Sprintf("%d is out of range", age),
        }
    }
    return age, nil
}

The handler uses errors.As to extract the details:

var ve *ValidationError
if errors.As(err, &ve) {
    // return 400 with ve.Field and ve.Message
}

I use this pattern a lot in API services. The transport layer (HTTP handler, gRPC interceptor) checks for *ValidationError and maps it to the right status code. Business logic never imports net/http. Clean separation.

The middleware pattern for HTTP error mapping

Instead of checking error types in every handler, I use a single middleware that converts Go errors into HTTP responses:

func ErrorMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        next.ServeHTTP(w, r)
    })
}

// In practice, I use a custom handler type:
type AppHandler func(w http.ResponseWriter, r *http.Request) error

func Wrap(h AppHandler) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        err := h(w, r)
        if err == nil {
            return
        }
        var ve *ValidationError
        switch {
        case errors.Is(err, ErrNotFound):
            http.Error(w, "not found", 404)
        case errors.Is(err, ErrUnauthorized):
            http.Error(w, "unauthorized", 401)
        case errors.As(err, &ve):
            w.WriteHeader(400)
            json.NewEncoder(w).Encode(map[string]string{
                "field": ve.Field, "error": ve.Message,
            })
        default:
            log.Printf("internal error: %v", err)
            http.Error(w, "internal error", 500)
        }
    }
}

Now handlers just return errors and the middleware does the mapping. This cut about 40% of the error handling boilerplate from my HTTP layer. Handlers went from 30-line functions with five error-response blocks to 10-line functions that return early on error.

Don’t wrap errors you can handle locally

One mistake I made early on: wrapping everything. If you can handle the error right where it happens, handle it. Don’t wrap it and pass it up just to look thorough:

// Overengineered
func ReadWithDefault(path, fallback string) (string, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        if errors.Is(err, fs.ErrNotExist) {
            return fallback, nil  // this is fine
        }
        return "", fmt.Errorf("read with default: %w", err)
    }
    return string(data), nil
}

The ErrNotExist case is handled locally. No need to wrap it and send it up. The caller asked for a default and got one. Only wrap and return errors that the current function can’t resolve.

This sounds obvious but it took me a while to internalize. The instinct in Go is to always propagate. Sometimes the right call is to log it, recover, and move on.

What I actually do in every new project

Here’s my checklist when starting a Go service now. It takes about 20 minutes and saves hours of confusion later:

  1. Create an errors.go in each package with 3-5 sentinel errors for that package’s failure modes.
  2. Every exported function wraps errors with fmt.Errorf("funcname: context: %w", err).
  3. Define *ValidationError and *NotFoundError (or similar) as custom types in the domain package.
  4. Write one Wrap function for the HTTP/gRPC layer that maps error types to status codes.
  5. Use errors.Is and errors.As at API boundaries. Never use string matching.

This gives me structured, chainable, inspectable errors from day one. When something breaks at 2am, the log line tells me exactly which function, which step, and what the underlying cause was.

I wrote about approaching tool evaluation with a practical lens before on this blog. Go’s error handling is a good example of a design that looks worse on paper than it works in practice. The verbosity is real but the tradeoff is that errors are values, not exceptions. You can test them, wrap them, inspect them, and route them. Once you have decent patterns in place, the if err != nil stops being noise and starts being the most readable part of your code.

More of my projects and writing at abrarqasim.com.