errores-go-patron-if-err-nil-custom

Error Handling in Go with if err != nil and Wrapping

  • 6 min

An error in Go is a value that represents that an operation could not complete successfully. It is not thrown through the air: it is returned and checked.

If you come from languages like Java, C#, Python, or JavaScript, your muscle memory is trained to look for the try-catch block. You are used to the program throwing an exception that “bubbles” up until someone catches it when something goes wrong.

In Go, there are no exceptions (well, panic exists, but it’s not for what you think).

In Go, errors are not catastrophic events that break the flow: they are values. An error is just another piece of data, like an integer or a string, that your function returns and that you have the responsibility to handle explicitly.

Let’s look at the if err != nil pattern, custom errors, and how to preserve their identity when adding context.

The error interface

The entire Go error system is based on a very simple interface built into the language:

type error interface {
    Error() string
}
Copied!

Anything that has an Error() method that returns a string is an error. It’s that simple.

The zero value of an interface is nil. Therefore, if a function returns an error equal to nil, it means everything went well.

An interface is only nil if it contains neither a dynamic type nor a value. If we return an error pointer with a nil value as error, the interface retains its type and will not be equal to nil. That’s why we must explicitly return nil when there is no error.

The if err != nil pattern

As we saw in the functions section, Go supports multiple return values. The standard pattern is for a function to return (result, error).

We try to execute the logic.

We check immediately if an error occurred.

If there is an error, we handle it (log, return, retry…).

If there is no error, we continue with the main flow.

package main

import (
    "fmt"
    "os"
)

func main() {
    // Try to open a file
    file, err := os.Open("config.json")

    // EXPLICIT CHECK
    if err != nil {
        // Handle the problem here
        fmt.Println("Hey, opening failed:", err)
        return // Important: Exit the function
    }

    // If we get here, we guarantee 'err' is nil and 'file' is valid
    defer file.Close()
    fmt.Println("File opened successfully")
}
Copied!

This way of working makes the control flow transparent. When reading the code, you know exactly where each line can fail. There are no exceptions jumping 20 layers up without you seeing them pass.

Creating basic errors

How do we generate our own errors? We have two main ways in the standard library.

errors.New for a static message

For simple errors that only need a fixed text. You need to import the errors package.

import "errors"

func Divide(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("cannot divide by zero")
    }
    return a / b, nil
}
Copied!

fmt.Errorf for a dynamic message

If you want to include data within the error message (like the ID that failed), we use fmt.Errorf. It works like Printf.

func FindUser(id int) (*User, error) {
    // ... logic ...
    return nil, fmt.Errorf("user with ID %d does not exist", id)
}
Copied!

Custom errors

Sometimes a simple string is not enough. What if we want to return an HTTP error code (404, 500) or an internal error code so the caller of the function can react differently?

Since error is an interface, we can create our own struct and implement the Error() method.

// Define our custom error type
type DatabaseError struct {
    Time    string
    Message string
    Code    int
}

// Implement the 'error' interface
func (e *DatabaseError) Error() string {
    return fmt.Sprintf("[%s] Error %d: %s", e.Time, e.Code, e.Message)
}

func ConnectDB() error {
    return &DatabaseError{
        Time:    "12:00",
        Message: "Connection refused",
        Code:    503,
    }
}
Copied!

Now, whoever calls ConnectDB receives an error, but can perform a Type Assertion (as we saw in the previous article) to access the 503 code.

Wrapping errors to add context

Since Go 1.13, error management took a leap forward with the concept of Wrapping.

Suppose an SQL query fails.

  1. The database layer returns “Connection error”.
  2. The business layer receives that error and wants to add context: “Failed to get user: Connection error”.

If we use %v when constructing a new error, we preserve the message, but we lose the identity of the original error. So that callers can inspect it, we use %w.

originalErr := errors.New("connection lost")
// Wrap the error
errWithContext := fmt.Errorf("query failed: %w", originalErr)
Copied!

errors.Is (Comparing sentinel errors)

Don’t use == to compare errors if they have been wrapped. Use errors.Is. It searches recursively within the error layers.

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

func main() {
    err := functionThatReturnsWrappedError()

    // Is the original error, at its core, an ErrNotFound?
    if errors.Is(err, ErrNotFound) {
        fmt.Println("OK, it's a 404, no problem.")
    }
}
Copied!

errors.As for finding an error type

If you used a custom Struct (like DatabaseError) and the error is wrapped, use errors.As to extract it.

err := functionThatFails()

var myDbError *DatabaseError

// Try to find a DatabaseError within the error chain 'err'
// If found, it stores it in 'myDbError' and returns true
if errors.As(err, &myDbError) {
    fmt.Println("The DB error code is:", myDbError.Code)
}
Copied!

Since Go 1.26 we can also write errors.AsType[*DatabaseError](err), which returns the found error and a boolean without needing a pointer to the destination.

Multiple errors at once

errors.Join allows combining multiple errors while preserving each one for errors.Is, errors.As, and errors.AsType. This is useful, for example, when multiple independent tasks fail and we want to return a single error containing all the causes.

err := errors.Join(readErr, closeErr)
Copied!

nil values are discarded, and if all are nil, errors.Join also returns nil.

Best practices for error handling

  1. Don’t ignore an error without a concrete reason: if the result is irrelevant by contract, document that decision; in other cases, handle it or return it.
  2. Handle the error first (Leftward indentation): Bad (Unnecessary Else):
if err != nil {
    return err
} else {
    // happy code...
}
Copied!

Good (Early Return):

if err != nil {
    return err
}
// happy code without indentation
Copied!
  1. Add context, don’t just pass the error: If a function fails, try to wrap the error with fmt.Errorf("failed to process order %d: %w", id, err) so the final log tells a complete story.