How to handle errors in Go
Go has no exceptions — errors are ordinary return values. Check them and return early: if err != nil { return err }. Add context on the way up with fmt.Errorf("doing x: %w", err), and at the top decide what happened with errors.Is (is it this error?) or errors.As (is it this type?).
error is just an interface with one method, Error() string, and a function that can fail returns one as its last result. There is nothing to catch and nothing to rethrow, which is why Go code reads as a column of if err != nil — the failure path is written out, not hidden. The whole skill is three verbs: create an error, wrap it with context as it travels up the call stack, and inspect it once, at the place that can actually decide. Each example runs on this page — hit Run, then edit the code and run it again.
1Check it and return earlyRecommended
The base pattern: call, check, return. errors.New makes an error from a fixed string; fmt.Errorf makes one from a format string when the message needs the offending values in it. Return the zero value alongside the error so a caller who ignores the check gets something harmless, and keep messages lowercase and unpunctuated — they get concatenated into longer sentences by the callers above you.
Output
Prints half: 5, then error: half: n must be even, then error: withdraw 80: balance is only 50 - balance still 50. Two details worth copying: the if-scoped form if _, err := f(); err != nil keeps err out of the surrounding scope so you can reuse the name, and withdraw returns a usable value next to its error. That is a choice — most functions return the zero value, and callers must not read the other results unless the docs say they are meaningful.
2Add context by wrapping with %w
A bare not found arriving at your log tells you nothing about which call produced it. Wrapping fixes that: fmt.Errorf("load user %d: %w", id, err) prefixes the context and keeps the original error reachable underneath. The %w verb is what builds the chain — %v formats the same text but throws the link away, so nothing above can match on the cause any more.
Output
The chain prints as GET /users/7: load user 7: not found — one line, read outside-in — and errors.Is still finds ErrNotFound at the bottom (true). errors.Unwrap peels one layer, giving load user 7: not found and then not found. The last two lines are the trap: the %v version prints identically, GET /users/7: not found, but errors.Is now returns false. Wrap with %w when callers may want to match the cause; use %v deliberately when you want the cause to stay a private implementation detail.
3Sentinel errors and errors.Is
A sentinel is a package-level error value — var ErrNotFound = errors.New(…) — that callers can compare against. Because everything is wrapped by the time it reaches the top, compare with errors.Is(err, ErrNotFound) rather than err == ErrNotFound: errors.Is unwraps the chain and checks every layer. Handle the cases in a switch with no expression, which reads far better than nested ifs.
Output
Prints 200 ada, 404 find "bob": user not found, 403 find "root": permission denied, then read 2 bytes: hi and done. The sentinel is what turns a string message into an API — the message can be reworded freely, but the identity is stable, so exporting one is a promise you have to keep. The standard library is full of them: io.EOF, sql.ErrNoRows, os.ErrNotExist, context.DeadlineExceeded. The string-to-int example shows the same check picking strconv.ErrRange out of a parse failure.
4Custom error types and errors.As
When the caller needs data and not just identity — which field failed, which line, which HTTP status — define a struct with an Error() string method. Use a pointer receiver and return &MyError{…}, so the type in the interface is *MyError and comparisons stay cheap. To get it back out, use errors.As(err, &target): it walks the chain looking for a value your target can hold and assigns it. A plain type assertion only ever inspects the outermost error.
Output
The custom error prints as age "-3": must not be negative and the assertion recovers its fields: field: age | reason: must not be negative. The second half wraps a real parse failure twice — load config: port: strconv.Atoi: parsing "12x": invalid syntax — and errors.As still digs out *strconv.NumError to print func: Atoi | input: 12x | cause: invalid syntax, while errors.Is(wrapped, strconv.ErrSyntax) is true. Two rules that save debugging time: the target passed to errors.As must be a pointer to the type you want (&ne where ne is a *strconv.NumError), and if you give your own type an Unwrap() error method returning a sentinel, errors.Is starts matching it too.
5Which check should you use?
| Check | Answers | Sees through %w | Best for |
|---|---|---|---|
| if err != nil | Did it fail? | N/A | The 95% case — add context and return |
| err == ErrNotFound | Is it this exact value? | No | Nothing — one wrap and it silently stops matching |
| errors.Is(err, ErrX) | Is it this exact value? | Yes | Sentinels — the default identity check |
| err.(*MyError) | Is it this type? | No | An error you created in the same function |
| errors.As(err, &target) | Is it this type? | Yes | Reading fields off a typed error |
6defer, panic, and recover — and when they are not error handling
panic is not Go's throw. It unwinds the stack running deferred functions and, unless something recovers, kills the process — so it belongs to bugs that invalidate your assumptions (a nil map you built yourself, an impossible switch case), not to a file that is missing or a request that timed out. recover works only inside a deferred function, and the one legitimate everyday use is at a boundary: convert a panic into an error rather than let it escape into a caller who never asked for one.
Output
safeDivide(10, 2) prints 5 <nil>, and the divide-by-zero comes back as a value: 0 safeDivide: runtime error: integer divide by zero. The recovery only works because err is a named result — a deferred closure can assign to it after return has already chosen the values, and with anonymous results there would be nothing to assign to. Then mustGet: no config key host, and finally the process pair, whose output shows the real reason defer exists: open bad.txt, close bad.txt, then the error — the cleanup runs on the failure path without you writing it twice. Note also that close ok.txt prints after read ok.txt: deferred calls run when the function returns, in last-in-first-out order.
7Collecting several errors with errors.Join
Return-early is wrong for validation: a form with three bad fields should report three problems, not the first one. Since Go 1.20, errors.Join combines any number of errors into one, skipping the nils and returning nil when they are all nil — so you can collect into a slice unconditionally and hand the whole thing to Join at the end. errors.Is and errors.As then search every branch, not just a single chain.
Output
A joined error prints one message per line — password too short then password needs a digit — which is the detail people trip over when they embed the result mid-sentence in a log. The next line is true true: both sentinels are still findable. A valid password gives valid: true, because errors.Join() of nothing is nil — no empty-slice special case needed. The last pair shows the same mechanism through fmt.Errorf with two %w verbs, which prints check failed: password too short; password needs a digit on one line and still answers true. Under the hood a joined error has Unwrap() []error rather than Unwrap() error, which is why errors.Unwrap returns nil for it — use errors.Is/As, or type-assert to interface{ Unwrap() []error } if you must enumerate them.
Frequently asked questions
Why does Go use if err != nil instead of exceptions?
Because an error is a normal return value, the failure path is visible in the code that produces it — you can see where a function can fail, and the compiler forces you to at least acknowledge the extra return value. Exceptions move that path out of sight and make the set of things a call can do unbounded. The cost is verbosity, which Go accepts on purpose; the idiom to keep it readable is to return early and to wrap with context rather than to nest.
What is the difference between errors.Is and errors.As in Go?
errors.Is(err, target) asks "is this the same error value?" and is for sentinels like io.EOF or your own ErrNotFound. errors.As(err, &target) asks "is there an error of this type in here?" and, when there is, assigns it to your variable so you can read its fields. Both unwrap the whole chain, which is what makes them safe after %w wrapping — the direct equivalents err == ErrX and err.(*MyError) only look at the outermost error.
Should I use %w or %v in fmt.Errorf?
Use %w when a caller might reasonably want to match the underlying error with errors.Is or errors.As — it produces the same message text but keeps the cause reachable. Use %v when the cause is an implementation detail you do not want to promise: wrapping makes the wrapped error part of your package’s API, so switching from one library to another can break callers who matched on it. The printed message is identical either way, which is exactly why the wrong choice is easy to miss in review.
How do I return more than one error from a Go function?
Collect them in a []error and return errors.Join(errs...), available since Go 1.20. Join skips nil entries and returns nil if every entry is nil, so no special-casing is needed for the happy path. The result prints one message per line, and errors.Is and errors.As search all of the joined errors. fmt.Errorf with two or more %w verbs does the same thing when you also want a prefix.
When should I use panic instead of returning an error in Go?
Almost never in library code. Panic is for programmer errors that mean the program’s assumptions are already broken — an impossible switch branch, a package-level regexp.MustCompile on a literal pattern, a nil dependency that should have been injected at startup. Anything the outside world can cause (bad input, missing file, dropped connection) is an ordinary error. If you do panic across a package boundary, recover at that boundary in a deferred function and return an error instead.
Why is my err != nil true when the function returned nil?
You almost certainly returned a typed nil pointer — a var e *MyError (nil) returned as an error. An interface value holds a type and a value, so an interface carrying (*MyError, nil) is not equal to nil even though the pointer is. Fix it by declaring the variable as error rather than *MyError, or by returning a literal nil on the success path instead of the pointer variable.