AlgoMaster Logo

Error Handling Patterns

Last Updated: May 22, 2026

High Priority
13 min read

Go's approach to errors is values you check, not exceptions you catch. The previous two chapters introduced the error interface and the constructors that produce error values. This chapter is about the day-to-day patterns Go code uses to actually handle those errors: when to check, when to return, when to defer, when to ignore, and how to keep all of that readable.

The if err != nil Pattern

The most common shape in Go is calling a function that returns a value and an error, then immediately checking the error.

The call to strconv.ParseFloat returns two values: the parsed number and an error. The if err != nil check asks whether anything went wrong. If the error is non-nil, the function returns immediately, passing the error back to its caller. If the error is nil, the function continues with the price.

That sequence (call, check, return on error, continue on success) repeats so often that reading Go code is largely a matter of recognizing it. The pattern looks repetitive on a page. That's deliberate: every error-producing call has a visible decision point right next to it, so you can tell at a glance where things might fail and what happens when they do.

A few important details about this pattern:

  • The error is always checked right after the call, never several lines later.
  • The return value paired with a non-nil error is the zero value of the success type. Callers shouldn't read it, and putting 0, "", or nil there makes that explicit.
  • The check uses != nil, not equality with some specific error value. Comparing to specific errors is a separate operation.

Error as the Last Return Value

When a Go function can fail, the error is the last value it returns. This is a convention, not a language rule, but the standard library and every well-written Go package follow it consistently.

chargeCustomer returns (string, error). reserveStock returns (int, int, error). In both signatures, the error sits at the end. The signature (T, error) is the standard form for any function that can fail.

There are two reasons this convention matters. First, it makes function signatures predictable. When you see func DoThing(...) (Result, error), you know immediately what the last return value is and that you need to check it. Second, it pairs naturally with the multiple-assignment form value, err := fn(...), where the success value comes first because that's what most code wants to use.

The pairing of "zero success value, real error" or "real success value, nil error" is also a convention worth following. When a function returns (T, error), the caller should be able to assume that:

  • If err == nil, the value is valid and usable.
  • If err != nil, the value is the zero value of T and should not be used.

Putting partial data in the success value when an error is also returned makes callers second-guess what they're allowed to do. Stick to one or the other.

Both versions compile and return the same shapes. The difference is what a careless caller sees. With badPlaceOrder, a caller that forgets to check err ends up with an order whose ID is the string "ORD-???", which looks real but isn't. With placeOrder, the zero-value order makes it obvious that something didn't work, even before the error is consulted.

Guard Clauses and the Un-indented Happy Path

Go code is shaped to put the success path at the leftmost column. Every error path returns early. The happy path stays at the same indentation level from the top of the function to the bottom.

Here's the shape, with a function that runs three steps where any one of them might fail:

Look at buildCart. There are three steps, and each one has an if err != nil that returns. After each check, the function continues at the original indentation. The final return Cart{...}, nil sits at the same level as the opening subtotal, err := .... The reader's eye can scan straight down the left edge of the function and see what success looks like.

Compare that with the same logic written using nested success branches:

Same output, but the body is a staircase. The success case is buried at the deepest indentation, and the error cases are scattered around the outside. It's harder to read, harder to add a new step to, and easier to introduce a bug like forgetting to return an error from one of the branches.

The diagram below shows the two shapes. The guard-clause version returns early on each error and keeps the happy path flat. The nested version pushes success deeper and deeper.

The pattern is sometimes called "early return" or "guard clauses". One small habit that helps: when you write the call, also write the if err != nil { return ..., err } block on the same keystroke, before moving on. Going back later to add error handling is when bugs creep in.

Handle Immediately or Propagate Up

Every error you get back has to be dealt with. There are basically two choices: handle it where you are, or pass it up to your caller. Picking the right one is a question of who has enough context to do something meaningful about the failure.

The clearest rule: handle errors at the layer that can do something useful about them, and propagate them everywhere else.

The low-level decrementStock knows whether stock is available; it doesn't know what to tell the customer. The mid-level addToOrder knows the steps of placing an order; it doesn't know who's checking out. The top-level handleCheckout knows the customer and the UI. So decrementStock and addToOrder return errors, and handleCheckout is where the decision happens.

The opposite anti-pattern is handling an error too early, at a layer that doesn't have enough information. If decrementStock printed "out of stock" itself and returned nil, the caller would think everything succeeded, "Order placed!" would print, and the error would have been silently swallowed. The program continues down a path that depends on the operation having worked, which is one of the most common ways production bugs hide for months.

Some questions to ask before deciding whether to handle or propagate:

  • Do I have the information I need to make a recovery decision here?
  • Is there a sensible default I can substitute for the failed result?
  • Does the caller still need to know that this step didn't fully succeed?

If the answer to the first two is no, propagate. If the answer to the third is yes, you must propagate even if you also do something locally.

Naming err, and Handling Multiple Errors

In Go, the conventional name for an error variable is err. Lowercase, three letters, consistent across the entire ecosystem.

Use err for the immediate error variable. The same name works in nested blocks because the scoping makes each new err shadow the outer one safely. When you see if _, err := f(); err != nil { ... }, that err lives only inside the if.

When a function makes several error-returning calls in sequence and assigns each to err, reusing the same name is fine and idiomatic:

Both calls write into err, and each if err != nil check is right next to its call. There's no ambiguity about which call produced the error, because by the time you reach the second check, the first one has already either returned or moved on.

Numbered names like err1, err2, errA, errB come up when you genuinely need to hold on to multiple errors at the same time and inspect them together. That's rare. Most code that looks like it needs err1 and err2 actually wants either an early return on the first error or a slice of errors.

Each validator runs independently, and the function gathers up every error into a slice. The caller can show all the problems at once instead of forcing the user to fix one, resubmit, and discover the next. We're still using err as the variable name inside each if, because we never need two errors live at the same instant.

Named Returns with err

Functions in Go can name their return values. When the error return is named err, you can assign to it inside the body and either return it explicitly or use a bare return.

The signature (qty int, err error) does two things. It documents what the function returns, and it declares qty and err as variables initialized to their zero values inside the function body. A bare return returns the current values of those named variables.

Most Go code keeps return values unnamed because the unnamed form is shorter and forces explicit returns, which read clearly. Named returns earn their place in two situations: documenting non-obvious return values, and combining with defer to modify the returned error before the function exits. The second case is the next section.

defer and Error Handling

defer schedules a function to run when the surrounding function returns. It's the standard tool for cleanup: close a file, release a lock, decrement a counter. The most common shape is a defer placed right after you acquire a resource.

The pattern is straightforward. Open the file. If opening failed, return the error immediately, because there's nothing to close. If opening succeeded, schedule f.Close() to run when the function returns. Then do the work. No matter how the function exits (normal return, error return, panic), Close runs.

There's a subtle issue with this shape, though. f.Close() itself can return an error. By ignoring it, you might miss a real problem: writes are sometimes buffered, and the buffer flush can fail at close time. For a file that's only being read, ignoring the close error is fine; for a file that's being written, you usually want to capture and report it.

The idiomatic way to handle "the close might fail" is to combine a named return value with a defer that assigns to err:

The signature names the error return err. The deferred closure calls f.Close() and inspects two things: whether the close itself failed, and whether the function is already on its way out with another error. If close failed and err was still nil, we assign the close error to err. The bare return then returns the up-to-date value.

The reason for the && err == nil guard is that an error from the main work is usually more important than one from the cleanup. If the write failed, that's what the caller should hear about. If the write succeeded but the close failed (a flush problem, for example), the close error becomes the one the caller sees.

The diagram below walks through the deferred-close pattern. The deferred function runs before control leaves the function and adjusts err if needed.

This shape applies to any code that handles file writes, network connections, or other resources where closing can produce a meaningful error. For read-only operations, the simpler defer f.Close() is fine and is what most code uses.

The same defer-plus-named-return shape shows up for rollbacks. A database transaction function can defer func() { if err != nil { tx.Rollback() } }() so that any error path triggers a rollback, while the success path commits inside the body. The deferred function reads the named err after the body has decided to return, and one piece of cleanup code covers every exit.

Ignoring Errors with _

Go's multiple-return syntax lets you discard a value with the blank identifier _. For errors, this is something to be very careful about: discarding an error means "I'm telling the compiler I checked this, but I'm choosing to do nothing about it".

The code compiles. It runs. It prints 0. The user has no idea the input was invalid because the error was thrown away. Multiply that by a real codebase with a hundred such places, and you get a system that silently produces wrong answers.

The default rule is: do not ignore errors. If a function returns an error, check it. If you don't have anything useful to do at that level, propagate it. At a top level where the only sensible action is to log and stop, do that.

There are a handful of cases where ignoring an error is defensible:

1. The function's documentation says it can never fail. A few standard library functions return an error only because the interface they implement requires it, but the implementation never actually produces one. bytes.Buffer.WriteString is the canonical example: it writes to memory, and there's no realistic failure mode. The pattern _, _ = b.WriteString(...) is at least loud enough that a reader notices the decision.

2. You've already validated the input separately. If a function takes a string that you already know is well-formed, the error from parsing it can be ignored on the grounds that the case is unreachable. The classic example is strconv.Itoa, which converts an int to a string and cannot fail because every int has a valid string form. It doesn't return an error at all, so there's nothing to ignore. The relevant case is a function like fmt.Fprintf to an io.Writer that you know is in-memory.

3. The error is genuinely uninteresting at this point. Sometimes a deferred Close() on a read-only file is so unlikely to fail in a way that matters that the conventional defer f.Close() is appropriate, even though it discards the error.

A useful test: if you can write a one-line comment explaining why ignoring the error is safe, the _ is probably fine. If you can't, you're hiding a bug.

In this version we don't even use _ because we don't assign the error at all. That's the cleanest form when the API guarantees no error and the caller has no use for the byte count either.

"Don't Just Log and Continue"

A subtler version of swallowing errors is logging them and carrying on as if nothing happened. The error doesn't disappear, but the calling code never gets to react to it.

The save failed, the log line shows it, and the email went out anyway. Now there's a customer who got a confirmation for an order that doesn't exist in the database. Nothing in the program acted on the failure.

The fix is to make the failure stop the dependent work:

Now the error propagates. The email isn't sent. The caller learns that the order didn't go through. That's the behavior you want.

The lesson isn't "never log". Logging is fine when it's the right action at the right layer. The mistake is treating logging as a substitute for handling. If the work below depends on the operation having succeeded, a failure has to stop or redirect that work, not just leave a trace in a log file.

The "Handle Once" Rule

A close relative of "log and continue" is doing both: logging the error and also returning it. The caller now logs and returns it too. By the time the error reaches the top, it's been logged five times in five different formats and nobody knows which log line is the real one.

The same root cause shows up in two log entries. In a real system, the on-call engineer reading the logs has to figure out whether these are two separate incidents or one. Multiply by every layer and the noise drowns out the signal.

The rule of thumb: handle the error once. Either log it (or take some other final action) or return it, not both. The natural place to log is at the boundary where the error is no longer going to travel further: the top of the request handler, the top of main, the worker that owns a goroutine. Every layer below that should propagate without logging.

One log line. It contains the context from each layer (fetch profile C-001 plus the underlying not found), so you can tell where it went wrong without having to stitch together separate log entries. The fmt.Errorf with %v adds context without losing the original message.

The same idea applies to returning errors: if a function adds useful context, return a new error that includes it. If it doesn't, return the error unchanged. The result is a single error that picks up context as it travels up the call stack, without being re-logged at every step.

A Note on Returning vs Collecting Multiple Errors

We saw earlier that some functions want to return every problem they found, not just the first one. The default for sequential operations is to return on the first error, because each step usually depends on the previous one having succeeded. There's no point trying to write to a file you couldn't open.

The collection pattern fits validation, batch processing, and other situations where the steps are independent and the user benefits from seeing all the problems at once. For collecting them into a single error value, Go 1.20 introduced errors.Join.

Two independent checks, two independent errors, returned as a slice the caller can iterate. For sequential operations where step 2 depends on step 1, you wouldn't use this shape; you'd return on the first failure.