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.
- Never use
_to ignore errors again. Every ignored error is a latent bug. If you absolutely must, comment and log. - Wrap every error with context:
fmt.Errorf("operation: %w", err). Add function, ID, parameters. Debugging time drops by 70%. - Define sentinel errors for expected cases (user not found, duplicate key) and use
errors.Isto compare. - Create a structured error type for your API with HTTP status and message. Do it once, reuse everywhere.
- 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.