Skip to Content
Course content

111: Error Formatting with fmt.Errorf and %w

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

When you first start handling errors in Go, you'll likely find yourself writing a lot of code that looks like this: fmt.Errorf("something went wrong: %v", err). On the surface, this feels correct. You're adding context to the error—telling the next person in the call stack exactly where the failure happened—and you're preserving the original error message. But there is a subtle, architectural trap here that I've seen trip up even senior devs moving to Go from other languages.

The Trap of String-Only Errors

Let's imagine we're building a user service. We have a function that fetches a user from a database. If the database returns a sql.ErrNoRows, the repository layer might wrap it like this:

func (r *UserRepository) GetUser(id string) (*User, error) {
    err := r.db.QueryRow("...", id).Scan(...)
    if err != nil {
        return nil, fmt.Errorf("repository failed to fetch user %s: %v", id, err)
    }
    return user, nil
}

This looks fine in your logs. You'll see "repository failed to fetch user 123: sql: no rows in result set". But here is where it breaks: the original error has been "flattened." By using %v, you've turned the error into a simple string. If the calling function—say, your API handler—needs to know if the error was specifically a "Not Found" error so it can return a 404 status code instead of a 500, it's out of luck. It can't use errors.Is(err, sql.ErrNoRows) anymore because the original error is gone, buried inside a new string.

Preserving the Chain with %w

This is why Go introduced the %w verb. It stands for "wrap." When you use %w instead of %v, you aren't just formatting a string; you're creating a wrapper that keeps the original error alive inside the new one. Let's look at the fix:

return nil, fmt.Errorf("repository failed to fetch user %s: %w", id, err)

By changing that one character, you've created an error chain. Now, the API handler can do this:

user, err := repo.GetUser("123")
if errors.Is(err, sql.ErrNoRows) {
    // Now we can actually detect the root cause!
    http.Error(w, "User not found", http.StatusNotFound)
    return
}

I like to think of %w as a way of adding a layer of onion skin. You're adding your own context (the outer layer), but you're leaving the core (the original error) intact so that anyone further up the line can still peel it back and see what actually happened.

Knowing When to Stop Wrapping

Now, a word of caution: don't wrap everything by default. There is a legitimate reason to use %v. Wrapping exposes the internal implementation details of your function to the caller. If you're writing a library and you're calling a third-party API, you might not want your users to be able to use errors.Is on that third-party error. If you do that, your users' code becomes coupled to the specific library you chose to use internally.

If you want to provide a clean, abstracted boundary where the caller knows that it failed, but shouldn't care how it failed deep in the guts of your system, use %v. Otherwise, if you're moving errors up through your own internal layers, %w is almost always the right call.




📋 Practical Task

Refactoring the Config Loader Error Chain

You have a configuration loader that reads a JSON file from disk. Currently, it uses %v to format errors, which is making it impossible for the main application to distinguish between a "File Missing" error (which should trigger a default config) and a "Malformed JSON" error (which should crash the app).

Your Task: Modify the LoadConfig and readConfigFile functions to use %w. Then, implement a check in the main function using errors.Is to detect if the error is os.ErrNotExist.

package main

import (
    "errors"
    "fmt"
    "os"
)

func readConfigFile(path string) ([]byte, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        // TODO: Wrap this error correctly
        return nil, fmt.Errorf("could not read file %s: %v", path, err)
    }
    return data, nil
}

func LoadConfig(path string) error {
    _, err := readConfigFile(path)
    if err != nil {
        // TODO: Wrap this error correctly
        return fmt.Errorf("config loading failed: %v", err)
    }
    return nil
}

func main() {
    err := LoadConfig("config.json")
    if err != nil {
        // TODO: Check if the root cause is os.ErrNotExist
        fmt.Println("Generic error occurred:", err)
    }
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.