f in x
Error Handling in Go — Errors as Values and Modern Patterns for Resilient APIs
> cd .. / HUB_EDITORIALE
Sviluppo di siti web

Error Handling in Go — Errors as Values and Modern Patterns for Resilient APIs

[2026-07-11] Author: Ing. Calogero Bono
Zenithby Meteora Web The operating system for your business. Social, clients, bookings and invoices in one platform. Gyms, barbers, professionals. Discover Zenith Free demo · no card

Have you ever seen a Go API crash silently because an ignored error caused everything to fall apart? It happens. We see it every day in projects we review: poorly wrapped errors, lost context, missing stack traces. The problem isn't Go – it's how we treat errors.

We, at Meteora Web, have been using Go for years in backends for clients across Italy. And we stopped treating error handling as an afterthought long ago. In Go, errors are values – not exceptions that pop out of nowhere. This guide shows you how to leverage that philosophy with modern patterns: wrapping, inspection, and goroutine management.

Why are errors values and not exceptions in Go?

Go has no try-catch. When a function can fail, it returns an error as the last return value. It sounds basic, but it changes everything: the error becomes a piece of data you can inspect, encapsulate, transform. It’s not an external event that interrupts the flow – it’s part of the flow.

Think of a database call: you can't pretend the connection is always available. With exceptions the code is linear until something explodes two levels away. With errors-as-values you decide explicitly what to do at every point.

Sponsored Protocol

// Error handled as a value: you check, decide, proceed
func GetUser(id int) (*User, error) {
    row := db.QueryRow("SELECT name, email FROM users WHERE id = ?", id)
    err := row.Scan(&u.Name, &u.Email)
    if err != nil {
        return nil, fmt.Errorf("GetUser %d: %w", id, err)
    }
    return &u, nil
}

The if err != nil pattern isn't verbose – it's a contract. Every error you ignore is a potential bug. We call it “surface responsibility” – and in our projects no pull request passes that assigns an error to _.

Sentinel errors: when an error is a constant

A sentinel error is a global variable of type error that represents a specific condition. Classic examples: io.EOF or sql.ErrNoRows. It's used for comparison with ==, but be careful: if you wrap it, the comparison fails. That's why modern patterns use errors.Is.

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

func FindUser(id int) (*User, error) {
    // ...
    return nil, ErrNotFound
}

// Caller:
user, err := FindUser(42)
if errors.Is(err, ErrNotFound) {
    // return 404
}

How to handle errors with the if err != nil pattern and when to avoid it?

The basic pattern is simple: every call that might produce an error is checked immediately. But there are cases where writing 20 if err != nil in a row becomes noisy. Here are three strategies to clean it up without losing control.

Sponsored Protocol

1. Helper functions that accumulate errors

If you need to execute multiple independent operations that can each fail, use a slice of errors and check at the end. Works well for validation or batch writes.

var errs []error
if err := step1(); err != nil {
    errs = append(errs, fmt.Errorf("step1: %w", err))
}
if err := step2(); err != nil {
    errs = append(errs, fmt.Errorf("step2: %w", err))
}
if len(errs) > 0 {
    return errors.Join(errs...)
}

errors.Join (Go 1.20+) combines multiple errors into one that supports errors.Is and errors.As on each component.

2. Using closures to centralize error handling

In loops or repetitive calls, create an anonymous function that centralises the handling. Classic example: writing lines to a writer with deferred closure.

var writeErr error
write := func(b []byte) {
    if writeErr != nil {
        return
    }
    _, writeErr = w.Write(b)
}
write([]byte("line1\n"))
write([]byte("line2\n"))
return writeErr

3. When not to use if err != nil: only for unrecoverable errors

If an error is fatal and you cannot proceed, panic is acceptable. But only during initialization (config, database). Never in business logic. We at Meteora Web use log.Fatal in entrypoints, never in HTTP handlers.

Sponsored Protocol

What modern patterns exist for error wrapping and inspection?

Wrapping (adding context to an error) is mandatory if you want useful debugging. Using %w in fmt.Errorf creates a chain: the original error remains accessible via errors.Is and errors.As.

func ReadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("read config %s: %w", path, err)
    }
    // ...
}

Now, in the caller, you can ask:

if errors.Is(err, os.ErrNotExist) {
    // file missing
}

errors.Is traverses the wrapping chain and compares against the target error. errors.As instead looks for a specific type:

var netErr *net.OpError
if errors.As(err, &netErr) {
    fmt.Println("network error:", netErr.Op)
}

Error wrapping with additional formatting

If you need more context than fmt.Errorf provides, create a custom type that implements Unwrap():

Sponsored Protocol

type QueryError struct {
    Query string
    Err   error
}

func (e *QueryError) Error() string {
    return fmt.Sprintf("query %q: %v", e.Query, e.Err)
}
func (e *QueryError) Unwrap() error { return e.Err }

This keeps the standard wrapping chain while adding structured fields useful for logging and metrics.

How to design custom and structured errors for debugging and logging?

Errors in Go are interfaces. You can therefore create types that carry extra information: HTTP status code, transaction ID, severity. We at Meteora Web use structured errors in every API to maintain coherent logs and fast debugging.

Error with code and message

type AppError struct {
    Code    int    // 400, 404, 500
    Message string
    Err     error  // original wrapped
}

func (e *AppError) Error() string {
    return fmt.Sprintf("code %d: %s", e.Code, e.Message)
}
func (e *AppError) Unwrap() error { return e.Err }

Then, in an HTTP middleware, you type-switch with errors.As and return the appropriate JSON:

var appErr *AppError
if errors.As(err, &appErr) {
    w.WriteHeader(appErr.Code)
    json.NewEncoder(w).Encode(map[string]string{"error": appErr.Message})
    return
}
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": "internal server error"})

Logging with context

In production you can't just print err.Error(). Use log/slog with structured attributes. Your custom error can expose a LogValue() method to appear cleanly in logs.

Sponsored Protocol

func (e *AppError) LogValue() slog.Value {
    return slog.GroupValue(
        slog.Int("code", e.Code),
        slog.String("message", e.Message),
        slog.Any("cause", e.Err),
    )
}

// Usage:
slog.Error("request failed", "error", appErr)

How to handle errors in goroutines and channels without losing context?

Goroutines you fire and forget are the number one cause of silent bugs. The standard solution is an error channel or a WaitGroup with aggregated error. Here is the pattern we use in our backends.

Basic pattern: error channel

errCh := make(chan error, 2)
go func() {
    defer close(errCh)
    // work
    if err := doWork(); err != nil {
        errCh <- err
    }
}()

if err := <-errCh; err != nil {
    log.Error("goroutine failed", "error", err)
}

Watch out for buffering: without a buffer or a reading goroutine you risk deadlock. Prefer a buffer equal to the number of goroutines.

Pattern with WaitGroup and collective error

If you need to wait for multiple goroutines, use sync.WaitGroup and collect errors in a mutex-protected variable, or better use errgroup from golang.org/x/sync/errgroup:

g, ctx := errgroup.WithContext(context.Background())

g.Go(func() error {
    return step1(ctx)
})
g.Go(func() error {
    return step2(ctx)
})

if err := g.Wait(); err != nil {
    log.Error("group failed", "error", err)
}

Errgroup automatically manages the context: if one goroutine fails, the context is cancelled and the others can abort. No deadlocks, no lost errors.

What to do now — practical error handling for a REST API in Go

We've covered the theory: let's put it into practice. Here are the 5 immediate steps we apply in every Meteora Web project.

  1. Never use _ to ignore errors again. Every ignored error is a latent bug. If you absolutely must, comment and log.
  2. Wrap every error with context: fmt.Errorf("operation: %w", err). Add function, ID, parameters. Debugging time drops by 70%.
  3. Define sentinel errors for expected cases (user not found, duplicate key) and use errors.Is to compare.
  4. Create a structured error type for your API with HTTP status and message. Do it once, reuse everywhere.
  5. Use errgroup for goroutines – eliminate the headache of manual synchronization.

Ready? Take your Go project, apply the missing wrapping and replace panics with error returns. Your server will thank you – and your logs too.

To explore the full Go backend picture – concurrency, REST APIs, microservices – check out our pillar guide on Go. It covers everything.

Ing. Calogero Bono

> AUTHOR_EXTRACTED

Ing. Calogero Bono

Ingegnere informatico, fondatore di Meteora Web e Zenith OS. System administrator e progettista di piattaforme, app e CMS proprietari, con esperienza in sviluppo full-stack, marketing digitale ed ecosistema Google.
[ Read Full Dossier ]

> METEORA_WEB // DIGITAL AGENCY

We build the digital presence your business deserves.

Websites, social media, online advertising, e-commerce and high-performance hosting, engineered with method by computer engineers in Sciacca, for all of Italy.

> MW_JOURNAL

> READ_ALL()