Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 6

Go Error Handling Basics

Recommended resources

Hello! Let's dive into your next Go lesson.

In our last session, we established that Go's error type is an interface. This simple but powerful design choice allows for rich, structured error handling. You learned to create your own types that satisfy the error interface, enabling you to pass more context than just a simple string.

Today, we will build directly on that foundation to cover Go's modern and idiomatic error handling patterns. This lesson is crucial. In a complex, distributed system like a Kubernetes operator, errors are not exceptional; they are a normal part of the control loop. Knowing how to properly create, inspect, and add context to errors is fundamental for writing the robust, debuggable reconciliation logic you'll find in the Redis operator.

Our learning outcome is to implement Go's standard error handling pattern using the 'error' interface and fmt.Errorf. Specifically, we'll focus on the powerful error wrapping features introduced in Go 1.13.

1. Recap: The Basics of Error Handling

As a quick refresher, the idiomatic way to handle errors in Go is to check the error value returned from a function:

// result, err := someFunction() is a common pattern
if err != nil {
    // Handle the error
    return err
}
// If we get here, err was nil, so we can use the result

In the last lesson, you saw how to create custom error structs. Go also provides two simpler, built-in ways to create errors.

  1. errors.New(message string): Creates a simple error with a static message. These are often declared as global variables to be used as sentinel errors—well-known values that can be checked for equality.
  2. fmt.Errorf(format string, args ...any): Creates a new error with a formatted message, similar to fmt.Printf.

This short video provides a great demonstration of the standard error return pattern and how fmt.Errorf is used to create dynamic error messages.

The secret to making Golang error handling a breeze

The video 'The secret to making Golang error handling a breeze' by the Earthly channel shows the fundamental pattern of returning an error alongside a result and creating a formatted error string.

Watch the segment from 01:37 to 02:53. Pay attention to how the divide function returns both a result and an error, and how fmt.Errorf is used to include the specific values that caused the error in the message.


2. The Problem: Losing Context

Using fmt.Errorf with the %v verb, as shown in the video, is a common way to add context.

// Low-level function
func findConfig() error {
    return errors.New("config file not found")
}

// Higher-level function
func startService() error {
    err := findConfig()
    if err != nil {
        // We add context, but we are creating a BRAND NEW error.
        return fmt.Errorf("failed to start service: %v", err)
    }
    return nil
}

The error returned by startService will be: failed to start service: config file not found.

This looks good for a human reading a log file, but there's a problem. The new error is just a string. We have lost the original errors.New("config file not found") error value. Any code that receives this new error has no way to programmatically check if the root cause was config file not found. It can only parse the string, which is brittle and considered bad practice.

This is a significant issue in complex software. We want to add context without erasing the original, underlying cause.


3. The Solution: Error Wrapping

To solve this, Go 1.13 introduced a formal mechanism for error wrapping. You can use a special formatting verb, %w, with fmt.Errorf to create a new error that wraps the original.

Let's modify our previous example:

func startService() error {
    err := findConfig()
    if err != nil {
        // Using %w instead of %v
        return fmt.Errorf("failed to start service: %w", err)
    }
    return nil
}

This creates a chain of errors. The new error "contains" the original one. You can think of it like a set of nested dolls, where each layer adds more context.

Go Error Wrapping Scheme
This diagram illustrates the concept of error wrapping. The original 'error' is at the center. Each call to `fmt.Errorf("... %w", err)` adds a new layer of context ('wrap #1', 'wrap #2', etc.) around the previous one, forming an error chain that preserves the full history of the error.

For a concise, code-first explanation of this, the "Go by Example" site is excellent.

Errors - Go by Example

The 'Errors' page on 'Go by Example' provides a runnable example that contrasts simple errors with wrapped errors.

Read the sections that introduce a sentinel error (ErrOutOfTea) and show how it's wrapped using fmt.Errorf with the %w verb. Notice how the higher-level error making tea: ... is created.


4. Inspecting Wrapped Errors

Now that we have this chain of errors, how do we inspect it? The errors package provides two key functions for this.

errors.Is(err error, target error) bool

The errors.Is function checks if any error in the chain matches the target. This is the modern way to check for sentinel errors. It will walk down the chain and return true if it finds a match.

var ErrConfigNotFound = errors.New("config file not found")

func findConfig() error {
    return ErrConfigNotFound
}

func startService() error {
    err := findConfig()
    if err != nil {
        return fmt.Errorf("failed to start service: %w", err)
    }
    return nil
}

func main() {
    err := startService()
    if errors.Is(err, ErrConfigNotFound) {
        fmt.Println("Could not find configuration. Let's create a default one.")
        // This block will execute!
    }
}

If we had used err == ErrConfigNotFound, it would be false because the top-level error is the one created by fmt.Errorf. errors.Is lets us look "inside" the wrapped error.

errors.As(err error, target any) bool

The errors.As function checks if any error in the chain can be assigned to the target type. This is used when you have a custom error struct and need to access its fields. If it finds a match, it assigns the error to the target and returns true.

The secret to making Golang error handling a breeze

This video provides a fantastic walkthrough of error wrapping and how to use errors.Is and errors.As to inspect the error chain.

Please watch these three segments: Sentinel Errors (03:39 - 04:55): This explains how to define specific error variables and use errors.Is to check for them. Custom Error Types (04:55 - 06:48): This shows how to create a custom error struct to carry more data and use errors.As to extract it. Error Wrapping (08:27 - 11:35): This is the most important part. It ties everything together, showing how fmt.Errorf with %w builds a chain and how errors.Is and errors.As can inspect the entire chain.


5. API Design: The "Whether to Wrap" Dilemma

Now for the most critical question: when should you use %w (wrap) versus %v (don't wrap)?

This is an API design decision.

  • Wrap an error (%w) when you want to expose the underlying error to callers. This makes the underlying error part of your function's public contract. The caller can then use errors.Is or errors.As to react to that specific underlying error.

  • Do not wrap an error (%v) when the underlying error is an implementation detail. If you wrap it, you are committing to that error type. If you later refactor your function to use a different library that produces a different error, you might break your callers who were checking for the old error type. Hiding the error preserves abstraction.

The official Go blog post that introduced this feature provides the best guidance on this topic.

Working with Errors in Go 1.13 - The Go Programming Language

The official Go blog post 'Working with Errors in Go 1.13' contains a crucial section on the design implications of error wrapping.

Please read the sections titled 'Wrapping errors with %w' and, most importantly, 'Whether to Wrap'. This will clarify the distinction between exposing an error as part of your API versus hiding it as an implementation detail.

In the context of the Redis operator:

  • A good case for wrapping (%w): Your controller tries to get a Pod from the Kubernetes API server and gets a "not found" error. You might wrap this error and return it. The calling logic might want to know if the Pod is missing to decide whether to create it. k8s.io/apimachinery/pkg/api/errors.IsNotFound(err) is a helper that effectively does this check for you.
  • A good case for not wrapping (%v): The operator has an internal function that validates a Redis config file by running it through a temporary Lua script interpreter. If the Lua interpreter fails, you should return a generic "invalid config" error. You should not wrap the specific Lua error, because the fact that you use Lua is an implementation detail that could change.

Conclusion

You've now covered the complete, modern approach to error handling in Go. This is a pattern you will use constantly.

Key Takeaways:

  • Errors are values that implement the error interface.
  • Use fmt.Errorf to create errors with dynamic information.
  • Use the %w verb with fmt.Errorf to wrap an error, which creates an error chain and preserves the original error's context.
  • Use errors.Is() to check if a specific sentinel error exists anywhere in the chain.
  • Use errors.As() to check for and extract a custom error type from the chain.
  • The choice to wrap (%w) or not (%v) is an important API design decision about whether you are exposing an underlying error as part of your function's contract.

In our next lesson, we will shift gears from sequential Go code to the foundation of Kubernetes controllers: concurrency. You'll learn how to use goroutines to perform tasks in the background and channels to communicate with them safely. This is the engine that powers the reconciliation loop.