Step 05 of 24
Error handling
Return, wrap, inspect and combine errors idiomatically with fmt.Errorf %w, errors.Is, errors.As and errors.Join.
Go has no exceptions. A function that can fail returns an error as its last result, and the caller decides what to do with it, right there, in ordinary code. This feels verbose for a day and then becomes one of Go's strengths: every failure path is visible when you read a function. This lesson covers creating errors, adding context by wrapping, inspecting errors with errors.Is and errors.As, combining several errors, and a strategy for handling them well in a backend.
Errors are values
error is a built-in interface with one method:
type error interface {
Error() string
}Any type with an Error() string method is an error. You will mostly create them with two functions:
err1 := errors.New("empty username")
err2 := fmt.Errorf("username %q is longer than %d characters", name, maxLen)A function that can fail returns the error last. On failure it returns zero values for the other results; on success it returns nil for the error:
func parseAge(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("parse age: %w", err)
}
if n < 0 || n > 150 {
return 0, fmt.Errorf("age %d out of range", n)
}
return n, nil
}The caller checks immediately:
age, err := parseAge("abc")
if err != nil {
fmt.Println(err) // parse age: strconv.Atoi: parsing "abc": invalid syntax
return
}
fmt.Println(age)Keep the happy path at the left margin and handle errors in short if blocks that return early. Avoid else after a return. Two style conventions matter because errors get chained together: messages start lowercase and have no trailing punctuation, so "open config: permission denied" reads naturally.
Wrapping: adding context
An error like connection refused on its own is nearly useless in a log. Which connection? Doing what? Each layer that returns an error should add what it was trying to do. fmt.Errorf with the %w verb does that and wraps the original, keeping it available for inspection:
func loadUser(id int) (*User, error) {
row, err := queryUser(id)
if err != nil {
return nil, fmt.Errorf("load user %d: %w", id, err)
}
return row, nil
}The resulting chain reads like a stack trace in prose:
handle profile: load user 42: dial tcp 10.0.0.5:5432: connect: connection refused%w and %v produce the same text. The difference is that %w keeps the original error reachable via errors.Unwrap, errors.Is and errors.As; %v flattens it into a string. Use %w by default. Use %v deliberately when you do not want callers depending on an internal detail, for example a storage package that should not expose which database driver it uses.
Sentinel errors and errors.Is
Sometimes the caller needs to know which error happened, to choose a different path. The simplest tool is a sentinel error: an exported package-level variable that callers compare against.
var ErrNotFound = errors.New("not found")
func findUser(id int) (*User, error) {
u, ok := users[id]
if !ok {
return nil, fmt.Errorf("find user %d: %w", id, ErrNotFound)
}
return u, nil
}Because of wrapping, the returned error is not == to ErrNotFound. errors.Is walks the chain and reports whether any error in it matches:
_, err := findUser(7)
fmt.Println(err == ErrNotFound) // false: it's wrapped
fmt.Println(errors.Is(err, ErrNotFound)) // true
if errors.Is(err, ErrNotFound) {
// e.g. respond 404 instead of 500
}The standard library uses sentinels widely: io.EOF (end of input), fs.ErrNotExist (missing file), context.DeadlineExceeded, and sql.ErrNoRows (a query matched nothing), which you will meet in the database lessons.
Custom error types and errors.As
A sentinel carries no data. When the caller needs details, define an error type:
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return e.Field + ": " + e.Reason
}
func validateEmail(email string) error {
if !strings.Contains(email, "@") {
return &ValidationError{Field: "email", Reason: "must contain @"}
}
return nil
}errors.As searches the chain for an error of a given type and, if it finds one, assigns it to your variable. You pass a pointer to a variable of the type you want:
err := fmt.Errorf("signup: %w", validateEmail("ana.example.com"))
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Println("bad field:", ve.Field) // bad field: email
}ve has type *ValidationError because that is the type validateEmail returns. Pick one form (pointer or value) for each error type and use it consistently; a pointer is conventional. Get it wrong and errors.As never matches. If the target is not a pointer to an error type or interface, errors.As panics, and go vet flags the mistake.
When should you use which?
| Need | Tool |
|---|---|
| Just a message, nobody branches on it | errors.New / fmt.Errorf |
| Callers branch on a fixed condition | Sentinel + errors.Is |
| Callers need structured details | Custom type + errors.As |
A custom type can also opt in to errors.Is matching by implementing Is(target error) bool, or expose a wrapped cause with Unwrap() error. You rarely need either, but it explains how the standard library's richer errors work.
Combining errors with errors.Join
Some operations produce several independent errors: validating every field of a form, or closing several resources. errors.Join bundles them into one error that still works with errors.Is and errors.As:
func validate(name, email string) error {
var errs []error
if name == "" {
errs = append(errs, &ValidationError{Field: "name", Reason: "required"})
}
if err := validateEmail(email); err != nil {
errs = append(errs, err)
}
return errors.Join(errs...) // nil if errs is empty or all nil
}
func main() {
err := validate("", "nope")
fmt.Println(err)
}name: required
email: must contain @The message joins each error with a newline. errors.Join returns nil when every argument is nil, so you can return it directly. fmt.Errorf can also wrap more than one error at once: fmt.Errorf("sync failed: %w; cleanup failed: %w", err1, err2).
A classic use is capturing the error from closing a file you wrote to, which can report a failed flush. A named result lets a deferred function update the returned error:
func writeReport(path string, data []byte) (err error) {
f, err := os.Create(path)
if err != nil {
return err
}
defer func() {
err = errors.Join(err, f.Close())
}()
_, err = f.Write(data)
return err
}A strategy for handling errors
Knowing the tools is half the job. The other half is deciding what to do at each layer.
Handle each error once. Either handle it (log it, retry, fall back, turn it into a response) or return it with context, but not both. Logging and returning produces the same failure several times in your logs with no extra information.
Decide at the edge. Low-level code wraps and returns. The top of the call stack, which in a backend is the HTTP handler, decides what the client sees: errors.Is(err, ErrNotFound) becomes 404, a *ValidationError becomes 422, and anything unexpected becomes 500 with the details logged, never sent to the client.
Panic only for bugs. panic stops normal execution and unwinds the stack, running deferred calls. Use it for "this cannot happen" situations: a programmer error, or a failed setup step at start-up such as an unparsable built-in template. Never panic for expected failures like bad input or a missing record. recover inside a deferred function can stop a panic; HTTP servers use it so one buggy request does not kill the process, as you will see in the middleware lesson.
Beware typed nils. As covered with interfaces, returning a nil *ValidationError as an error produces a non-nil error. Return a literal nil on success:
func check(s string) error {
var ve *ValidationError
if s == "" {
ve = &ValidationError{Field: "s", Reason: "required"}
}
return ve // BUG: non-nil error even when s != ""
}Practice
Write loadConfig(env map[string]string) (Config, error) where Config has Port int and Host string.
- If
HOSTis missing, record an error wrapping a sentinelErrMissing. - If
PORTis missing, record an error wrappingErrMissing. If it is present but not an integer between 1 and 65535, record a*RangeError{Key, Value string}. - Report all problems at once.
In main, call it with {"PORT": "99999"}, print the error, then print whether it contains ErrMissing and, using errors.As, which key had a range problem.
Show solution
package main
import (
"errors"
"fmt"
"strconv"
)
var ErrMissing = errors.New("missing")
type RangeError struct{ Key, Value string }
func (e *RangeError) Error() string {
return fmt.Sprintf("%s=%q is not a valid port", e.Key, e.Value)
}
type Config struct {
Port int
Host string
}
func loadConfig(env map[string]string) (Config, error) {
var cfg Config
var errs []error
host, ok := env["HOST"]
if !ok {
errs = append(errs, fmt.Errorf("HOST: %w", ErrMissing))
}
cfg.Host = host
if raw, ok := env["PORT"]; !ok {
errs = append(errs, fmt.Errorf("PORT: %w", ErrMissing))
} else if p, err := strconv.Atoi(raw); err != nil || p < 1 || p > 65535 {
errs = append(errs, &RangeError{Key: "PORT", Value: raw})
} else {
cfg.Port = p
}
if err := errors.Join(errs...); err != nil {
return Config{}, fmt.Errorf("load config: %w", err)
}
return cfg, nil
}
func main() {
_, err := loadConfig(map[string]string{"PORT": "99999"})
fmt.Println(err)
fmt.Println("missing:", errors.Is(err, ErrMissing))
var re *RangeError
if errors.As(err, &re) {
fmt.Println("range problem in", re.Key)
}
}load config: HOST: missing
PORT="99999" is not a valid port
missing: true
range problem in PORTerrors.Is and errors.As see through both layers: the outer %w wrap and the joined list inside it. On failure the function returns a zero Config, so callers cannot accidentally use half-loaded values.
Key takeaways
- Errors are ordinary values returned last; check them immediately and return early.
- Wrap with
fmt.Errorf("doing x: %w", err)so each layer adds context while keeping the original inspectable. - Use
errors.Isfor sentinel errors anderrors.As(with a pointer to a typed variable) for custom error types; never match on message text. errors.Joincombines independent failures into one error thaterrors.Is/errors.Ascan still search.- Handle each error once, translate errors to responses at the edge, and reserve
panicfor genuine bugs.
Check yourself
Quiz
0/4 correct · 4 left
1.What is the practical difference between fmt.Errorf("load: %w", err) and fmt.Errorf("load: %v", err)?
2.validateEmail returns *ValidationError values. Which call correctly checks for one anywhere in err's chain?
3.What does errors.Join(nil, nil) return?
4.A repository function fails to query the database. It logs the error and also returns it wrapped; the service layer does the same; the handler logs it again. What is the problem?
Concepts in this lesson
One-page notes on each idea, for review or a quick lookup later.
Finished reading?
Mark it complete to fill in your roadmap.