Right, let’s talk about the single most common decision you’ll make when writing Go: do you handle this error right here, or do you kick this problem up the chain for someone else to deal with? This isn’t just academic; getting this wrong is how you end up with either fragile code that crashes on the first hiccup or a sprawling mess of if err != nil blocks that obscure your actual logic.

The core principle is stupidly simple but profoundly important: A function should handle an error if and only if it has the proper context to make a sensible decision about it. If it doesn’t have that context, its job is to annotate the error with whatever it knows and send it packing back to its caller, who might have the missing piece of the puzzle.

Think of it like being handed a mysterious, smoking component in a machine shop. If you’re the expert on 18th-century steam valves and you recognize it, you fix it. If you have no idea what it is, you don’t just throw it in the trash and hope the machine works. You put a post-it note on it (“Found this sputtering near the main turbine”) and send it to the foreman. Your function is just one person in this chain of responsibility.

The Golden Rule: Handle When You Can, Return When You Can’t

Let’s make this concrete. You’re writing a function that parses a configuration file.

func parseConfigFile(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        // Can you handle this? What would you even do?
        // You can't create a config from a file that doesn't exist.
        // You have no idea what the calling code wants to do.
        // So you return it, maybe with some context.
        return nil, fmt.Errorf("failed to read config file %s: %w", path, err)
    }

    var config Config
    err = json.Unmarshal(data, &config)
    if err != nil {
        // Similarly, you can't fix broken JSON. The caller needs to know.
        return nil, fmt.Errorf("config file %s contains invalid JSON: %w", path, err)
    }

    return &config, nil
}

Here, parseConfigFile is a “leaf” function. Its job is to try something and report success or failure. It has no business making decisions on behalf of the application. The caller might have a default configuration to fall back to, or it might need to crash the program loudly. That’s not this function’s call to make.

The Art of Adding Context

Notice what we did above? We didn’t just return err. We wrapped it with fmt.Errorf and the %w verb. This is non-negotiable. Returning a raw os.ErrNotExist from deep in your call stack is useless. By the time it bubbles up to main, all you know is “something didn’t exist.” Was it the config file? The database certificate? The user’s avatar image? You’re left guessing.

Wrapping the error is like adding those post-it notes. Each function adds its own layer of context, creating a stack trace of human-readable information. The %w verb preserves the original error for things like errors.Is and errors.As, so you can still check for os.ErrNotExist at the top level if you need to, but you get the full story too.

Actually Handling an Error (A Rare Sight!)

So when do you handle it? When you have the context to actually do something intelligent. Let’s say you’re writing an HTTP server that tries to fetch a user’s profile.

func getUserProfileHandler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    userID := r.PathValue("id")

    profile, err := fetchUserProfileFromDatabase(ctx, userID)
    if err != nil {
        // NOW we can handle it. We have the context of an HTTP request.
        if errors.Is(err, sql.ErrNoRows) {
            http.Error(w, "User not found", http.StatusNotFound)
            return
        }
        // Log the full error for us, the developers
        log.Printf("Database error fetching user %s: %v", userID, err)
        // But send a generic message to the client
        http.Error(w, "Internal server error", http.StatusInternalServerError)
        return
    }

    json.NewEncoder(w).Encode(profile)
}

This handler is at the edge of your system. It has the full context of an HTTP request and can translate a low-level database error into a meaningful HTTP status code and message. This is the appropriate place to handle the error. It didn’t just return it; it made a logical decision based on the error type.

The Cardinal Sin: Ignoring Errors

Please, for the love of all that is holy, never do this:

// DO NOT DO THIS. I WILL FIND YOU.
json.Unmarshal(data, &obj)

The Go compiler lets you ignore return values. This is a trap. It’s the programming equivalent of seeing a “CHECK ENGINE” light and putting a piece of tape over it. If a function returns an error, you must consider it. At the very least, if you’ve thought about it and decided you’re sure it can’t happen or doesn’t matter, panic (yes, really) or log it aggressively so you know if your assumption was wrong.

// If you're absolutely, positively sure...
result, _ := strconv.Atoi("42") // This is probably fine, but even then, be cautious.

// A better approach for "this should never fail" is to panic, making the failure obvious.
val, err := strconv.Atoi("42")
if err != nil {
    panic("hardcoded value '42' failed to parse as int; the universe is broken")
}

The rule of thumb is this: if you’re writing a function and you find yourself asking “what should I do if this fails?”, the answer is almost always “I don’t know,” which means you should return the error. Let the code with more context—the code that called you—figure it out.