Go Error Handling Guide for Wrapping Checking and Logging
A working guide to Go error handling: the error interface, wrapping with %w, checking with errors.Is and As, logging once, and where panics fit.

Error handling is the part of Go that newcomers like least and experienced developers defend most. The rule is simple enough to state in a sentence: a function that can fail returns an error as its last result, and the caller decides what to do with it. There are no exceptions, no hidden control flow, and no try. The cost is a line or three of if err != nil after every call that can fail. The payoff is that you can read a function top to bottom and see every place it can stop.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This guide covers the whole practice in the order you meet it: what an error is, how to add context, how to check for a specific one, where to log, what to do about cleanup and goroutines, and when panic is right. Each topic has its own article for the details, linked as we go. It is part of the Go basics path. Everything below ran on Go 1.25.6 and the outputs are what the programs printed.
An error is a value
The error type is a built-in interface with a single method:
type error interface {
Error() string
}Anything with that method is an error. The standard library gives you two ways to make one without writing a type: errors.New for a fixed message and fmt.Errorf for a formatted one. A function that can fail returns the result and an error, and the caller checks it before using the result:
package main
import (
"errors"
"fmt"
"strconv"
)
var ErrNegative = errors.New("negative value")
func parsePositive(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, err
}
if n < 0 {
return 0, ErrNegative
}
return n, nil
}
func main() {
for _, in := range []string{"42", "-7", "x9"} {
n, err := parsePositive(in)
if err != nil {
fmt.Printf("%q: error: %v\n", in, err)
continue
}
fmt.Printf("%q: %d\n", in, n)
}
}"42": 42
"-7": error: negative value
"x9": error: strconv.Atoi: parsing "x9": invalid syntaxThree things to notice. The success path is not indented: the failure returns early and the happy path runs straight down the page, which is the main style rule of Go code. The error comes back with a zero value for the result (0), and the result must not be used when the error is non-nil. And ErrNegative is a package-level variable that callers can name, which is what lets them tell this failure from a parse error without matching strings. That pattern, a sentinel error, has costs worth knowing, and sentinel errors and their coupling covers them.
The reason the language does it this way is that failure is a normal outcome of calling code that touches the world, and a signature that hides it misleads the reader. Why Go returns errors instead of throwing exceptions compares the two models directly, and the original rationale is in the Go blog's Error handling and Go.
Add context as the error travels
The raw error from strconv.Atoi says invalid syntax. Nobody reading a log line that says only that knows which input, which function or which request it came from. The fix is to wrap the error with a description of what you were doing, using %w:
package main
import (
"errors"
"fmt"
"io/fs"
"os"
)
func readConfig(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
return data, nil
}
func startServer(path string) error {
if _, err := readConfig(path); err != nil {
return fmt.Errorf("start server: %w", err)
}
return nil
}
func main() {
err := startServer("/etc/does-not-exist.conf")
fmt.Println(err)
fmt.Println(errors.Is(err, fs.ErrNotExist))
var pe *fs.PathError
if errors.As(err, &pe) {
fmt.Println(pe.Op, pe.Path)
}
}start server: read config: open /etc/does-not-exist.conf: no such file or directory
true
open /etc/does-not-exist.confEach layer adds a short phrase in front of the error beneath it, and the printed message reads like a path down the stack: what the program was doing, then what that needed, then the operating system's complaint. Phrases are lower case, name the operation and omit words like "failed to" and "error", because the reader already knows it is an error (message style has the conventions). The wrapped chain stays inspectable: errors.Is finds fs.ErrNotExist three layers down and errors.As pulls out the *fs.PathError, so the code that decides what to do does not depend on the message text.
%w is the only verb that keeps the chain. %v formats the underlying error into a string and loses it, which is occasionally what you want, because wrapping makes the inner error part of your package's public behavior. Wrapping errors with %w covers when to wrap and when to deliberately cut the chain, and adding context without noise covers how much to say.
Check for a specific error
Sometimes the right response depends on which error it was. The tools are errors.Is for a particular value and errors.As for a particular type:
package main
import (
"errors"
"fmt"
"io"
"strings"
)
type QuotaError struct {
Used, Limit int
}
func (e *QuotaError) Error() string {
return fmt.Sprintf("quota exceeded: %d of %d", e.Used, e.Limit)
}
func upload(n int) error {
if n > 100 {
return fmt.Errorf("upload: %w", &QuotaError{Used: n, Limit: 100})
}
return nil
}
func main() {
r := strings.NewReader("")
_, err := r.ReadByte()
fmt.Println(errors.Is(err, io.EOF), err == io.EOF)
err = upload(130)
var q *QuotaError
if errors.As(err, &q) {
fmt.Println("over by", q.Used-q.Limit, "-", err)
}
}true true
over by 30 - upload: quota exceeded: 130 of 100The first line shows the standard sentinel, io.EOF, compared both ways: unwrapped it works with either, wrapped only errors.Is does, so use it from the start. The second part shows a custom type that carries data. A caller that gets a *QuotaError can read how far over the limit the upload was and show the user a useful message. Is and As explains which to pick, custom error types shows how to design one, and error design compares sentinels, types and opaque errors as an API choice for a package.
Handle an error once
An error should be handled in exactly one place. "Handled" means one of: the code recovered and carried on, it translated the error into a response, or it logged the error and stopped it travelling. If a function logs an error and also returns it, the caller will log it again, and the log fills with the same failure at three layers with three slightly different messages, none with the full story.
The usual shape is that library-like code returns errors with context and never logs, and the edge of the program (main, an HTTP handler, a job runner) makes the single decision:
package main
import (
"errors"
"fmt"
"log/slog"
"os"
)
func find(id int) error {
return fmt.Errorf("find user %d: %w", id, errors.New("not found"))
}
func handler(id int) {
if err := find(id); err != nil {
slog.Error("request failed", "err", err, "user_id", id)
}
}
func main() {
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
},
})))
handler(7)
}level=ERROR msg="request failed" err="find user 7: not found" user_id=7One structured log line, with the whole wrapped message in the err field and the user ID as a separate field you can filter on. The time is stripped here only to keep the output stable. Log errors once explains the rule and its exceptions, and wrapping across layers shows how it plays out when a request crosses a handler, a service and a repository.
Putting it together in main
The decisions above meet in main, which is the last place an error can go. A command line program has one job there: print the error once, where the user will see it, and exit with a non-zero status so that scripts and CI notice. Everything below main returns errors, and main is the only function that calls os.Exit:
package main
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
)
type Config struct {
Port int
Workers int
}
func parseConfig(kv map[string]string) (Config, error) {
var c Config
var errs []error
port, err := strconv.Atoi(kv["port"])
if err != nil {
errs = append(errs, fmt.Errorf("port: %w", err))
}
workers, err := strconv.Atoi(kv["workers"])
if err != nil {
errs = append(errs, fmt.Errorf("workers: %w", err))
}
if len(errs) > 0 {
return c, fmt.Errorf("parse config: %w", errors.Join(errs...))
}
return Config{Port: port, Workers: workers}, nil
}
func run(args []string) error {
kv := map[string]string{}
for _, a := range args {
k, v, _ := strings.Cut(a, "=")
kv[k] = v
}
cfg, err := parseConfig(kv)
if err != nil {
return err
}
fmt.Printf("listening on %d with %d workers\n", cfg.Port, cfg.Workers)
return nil
}
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "myapp:", err)
os.Exit(1)
}
}myapp: parse config: port: strconv.Atoi: parsing "": invalid syntax
workers: strconv.Atoi: parsing "": invalid syntax
exit status 1Run with no arguments, the program prints myapp: once and then the two problems, which go run follows with its own exit status 1 line. It reports both bad settings in one pass instead of making the user fix them one at a time, and exits with status 1. Only the first line carries the parse config prefix, because errors.Join separates its errors with newlines and the prefix is applied to the whole text once. errors.Join produced a multi-line error whose lines each carry their own wrapped cause, and the outer fmt.Errorf added the phrase for the layer. Reporting everything wrong in a single run is the kind of courtesy that only costs a slice and a loop, and it is the reason Join exists. The run function returns an error and has no idea it is a command line tool, so a test can call it directly with a slice of arguments. That is the shape the guessing game and the todo app use.
The boundaries: HTTP and users
At a network boundary an error is turned into something the other side can use. In an HTTP server that means a status code and a body, and the mapping should live in one place, not in every handler. A domain error such as "not found" becomes a 404, a validation failure a 422, and everything unexpected a 500 with a generic message and the details in the log, because internal text such as a SQL error is both unhelpful and a leak to a user. The two articles on this are error handling in HTTP handlers and mapping domain errors to status codes, and user-facing and internal errors covers the general rule of keeping the two messages separate.
Cleanup and the errors you forget
Three situations produce errors that are easy to drop.
defer and Close. defer f.Close() ignores the error Close returns, which for a file you have written to is the error that tells you the data did not reach the disk. For a file you only read, ignoring it is fine. For a write, capture it, which usually means a named result and a small deferred function (errors from deferred Close and named results and defer).
Several failures at once. A batch job that processes a hundred items should not stop at the first bad one if the rest are independent. errors.Join, added in Go 1.20, combines errors into one value that errors.Is and errors.As can still search (errors.Join and collecting multiple errors).
Goroutines. A return err inside a goroutine returns into the void. The error has to travel back over a channel, or through errgroup from golang.org/x/sync, and the caller has to wait for it (returning errors from goroutines and errors in pipelines).
All three have the same cure, which is to make the error path part of the design, not a tail added after the happy path works.
Panic is for something else
panic stops normal execution, runs deferred functions and, if nothing recovers, ends the program with a stack trace. It is not an exception system. Use it for situations that mean the program is wrong: an index out of range, a nil map write, a branch of a switch that "cannot happen". The runtime panics for several of those itself (every runtime panic). A panic is also acceptable at startup for a configuration that makes running pointless, which is where Must helpers live (the Must convention). It is not acceptable as a way for a library function to report that a file was not found.
recover exists, and its legitimate use is at a boundary that must survive one bad request: the HTTP server recovers panics per request so a nil dereference in a handler logs a stack trace and returns 500 instead of ending the process. Anywhere else a recover usually hides a bug (recover done right and panic or error).
Make the compiler's silence safe
The compiler requires that you use every variable, but it does not require you to read an error result: calling os.Remove(path) as a statement compiles, and the failure vanishes. Two tools close the gap. go vet ships with Go and catches a handful of mistakes, such as a lost Printf argument and a copied mutex. An unchecked-error linter finds the dropped results: errcheck does only that, and golangci-lint bundles it with others (finding unchecked errors with errcheck). Put one of them in CI. When ignoring an error is correct, say so in code, with _ = and a comment, not by omission (ignoring errors on purpose).
Test the error paths as seriously as the success path. The branches that handle failure are the ones that run least often in production and so have the most hidden bugs, and a table-driven test with a failing dependency is cheap (testing error paths).
The shape of a healthy codebase
Pulling the pieces together, a codebase that handles errors well tends to have the same few properties.
- Every function that can fail returns an
error, last, and callers check it on the next line. - Errors gain context as they rise, in lower case, without "failed to".
- Packages export sentinel values or types only when callers need to branch on them, and keep the rest opaque.
- Logging happens at the edges, once per failure, with structured fields.
- Boundaries translate: domain errors become status codes, and internal details never reach a user.
- Goroutines report failures to someone who waits.
- Panics appear for programmer errors and are recovered only at a deliberate boundary.
- A linter checks that none of the above was forgotten.
The most common mistakes are the opposite of those points, and error handling mistakes seen in code review collects them. If you are wondering whether a library would make this nicer, error handling libraries for Go says honestly which ones earn their place. And if the boilerplate itself bothers you, it is worth reading why the language kept it: reducing error handling boilerplate and the proposals Go declined explain the trade-offs the Go team weighed.
For the official account, read Working with Errors in Go 1.13 and the errors package documentation, which are short and cover Is, As, Unwrap and Join with examples.


