Skip to Content
Course content

150: Wrapping Errors with %w

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was reviewing a PR for a teammate who was building a complex payment processing pipeline. They had a deep call stack: the API handler called a service, which called a repository, which finally called the database driver. When an error happened at the bottom, they were using fmt.Errorf("payment failed: %v", err) to pass the error back up the chain. It looked fine in the logs, but we hit a wall when we tried to implement a retry mechanism. We needed to know if the error was a temporary network glitch (retryable) or a validation error (non-retryable). Because they used %v, the original error was converted into a plain string. The "type" of the error was gone, and we were stuck trying to do fragile string matching on error messages to decide whether to retry. That's a nightmare you want to avoid.

Adding Context without Obscuring the Root Cause

In Go, when you want to add a bit of helpful context to an error—like which user ID caused the failure or which file couldn't be opened—you have two main choices. If you use %v or %s in fmt.Errorf, you're essentially creating a brand new error that contains the old error's message as a string. You've effectively "flattened" the error.

The %w verb changes the game. It stands for "wrap." When you use %w, Go doesn't just copy the string; it creates a wrapper that holds a reference to the original error. This allows you to build a chain of errors, adding a layer of context at every level of your architecture while keeping the original root cause intact.

package main

import (
	"errors"
	"fmt"
)

var ErrDatabaseDown = errors.New("database is offline")

func connectDB() error {
	// Simulating a low-level failure
	return ErrDatabaseDown
}

func initializeApp() error {
	err := connectDB()
	if err != nil {
		// Wrap the error with %w to preserve ErrDatabaseDown
		return fmt.Errorf("app initialization failed: %w", err)
	}
	return nil
}

func main() {
	err := initializeApp()
	fmt.Println(err) 
	// Output: app initialization failed: database is offline
}

Unwrapping the Chain with errors.Is

Now, here is where the magic actually happens. Because we used %w, the error returned by initializeApp isn't just a string—it's a wrapper. We can use the errors.Is function to "peer through" all the layers of wrapping to see if a specific error exists anywhere in that chain.

If I had used %v in the example above, errors.Is(err, ErrDatabaseDown) would return false because the original error was destroyed. With %w, it returns true. I personally find this indispensable for writing clean boundary logic: your deep internal packages can define sentinel errors, and your top-level handlers can check for them without needing to know exactly how many layers of wrapping occurred in between.

// Continuing from the previous example...
func main() {
	err := initializeApp()

	if errors.Is(err, ErrDatabaseDown) {
		fmt.Println("Alert: The database is down! Triggering failover...")
	} else {
		fmt.Println("An unknown error occurred.")
	}
}

One quick word of caution: don't wrap everything blindly. Wrapping is for when the caller actually needs to programmatically react to the underlying cause. If you're just logging a message for a human to read and the error type doesn't matter, %v is perfectly fine. But the moment you think, "I might need to check if this was a TimeoutError later," reach for %w.




📋 Practical Task

Refactoring the File Storage Error Chain

You are working on a cloud storage library. Currently, the code is using %v to pass errors up, which is preventing the main application from detecting specific failure types. Your goal is to refactor the error handling to use %w so that the main function can identify when a PermissionDenied error has occurred.

Requirements:

  • Modify readDiskFile to wrap the sentinel error using %w.
  • Modify loadConfiguration to wrap the error from readDiskFile using %w.
  • Update the main function to use errors.Is to check if the resulting error is ErrPermissionDenied and print "Access Denied: Please check your system permissions" if it is.
package main

import (
	"errors"
	"fmt"
)

var ErrPermissionDenied = errors.New("permission denied")

func readDiskFile(path string) error {
	// Simulating a permission error from the OS
	return fmt.Errorf("disk read failed for %s: %v", path, ErrPermissionDenied)
}

func loadConfiguration() error {
	err := readDiskFile("/etc/config.yaml")
	if err != nil {
		return fmt.Errorf("could not load config: %v", err)
	}
	return nil
}

func main() {
	err := loadConfiguration()
	if err != nil {
		// TODO: Use errors.Is to check for ErrPermissionDenied
		fmt.Printf("Error: %v\n", err)
	}
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.