# Error handling

> Return, wrap, inspect and combine errors idiomatically with fmt.Errorf %w, errors.Is, errors.As and errors.Join.

Backend with Go roadmap · Foundations · 16 min read · https://levelcode.org/roadmaps/go-backend/go-errors

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:

```go
type error interface {
	Error() string
}
```

Any type with an `Error() string` method is an error. You will mostly create them with two functions:

```go
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:

```go
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:

```go
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.

> [!IMPORTANT]
> When a function returns an error, treat its other results as invalid unless its documentation says otherwise. A notable exception is `io.Reader.Read`, which can return `n > 0` bytes *and* `io.EOF` in the same call.

## 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:

```go
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:

```text
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.

```go
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:

```go
_, 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.

> [!WARNING]
> Never inspect errors by matching their text, as in `strings.Contains(err.Error(), "not found")`. Messages change, get wrapped, and get translated; your branch silently stops working. Compare with `errors.Is` or `errors.As`.

## Custom error types and errors.As

A sentinel carries no data. When the caller needs details, define an error **type**:

```go
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:

```go
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`:

```go
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)
}
```

```text
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:

```go
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:

```go
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 `HOST` is missing, record an error wrapping a sentinel `ErrMissing`.
- If `PORT` is missing, record an error wrapping `ErrMissing`. 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.

**Solution**

```go
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)
	}
}
```

```text
load config: HOST: missing
PORT="99999" is not a valid port
missing: true
range problem in PORT
```

`errors.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.Is` for sentinel errors and `errors.As` (with a pointer to a typed variable) for custom error types; never match on message text.
- `errors.Join` combines independent failures into one error that `errors.Is`/`errors.As` can still search.
- Handle each error once, translate errors to responses at the edge, and reserve `panic` for genuine bugs.

## Quiz

1. What is the practical difference between fmt.Errorf("load: %w", err) and fmt.Errorf("load: %v", err)?

   A. %w produces a longer message that includes a stack trace
   B. %v is faster but only works with sentinel errors
   C. %w keeps err in the chain so errors.Is and errors.As can find it; %v only copies its text
   D. There is no difference; %w is an alias for %v

   **Answer: C.** Both produce identical text. Only %w wraps, so the original error stays reachable through Unwrap, errors.Is and errors.As. Go errors never include stack traces automatically.

2. validateEmail returns *ValidationError values. Which call correctly checks for one anywhere in err's chain?

   A. var ve *ValidationError; errors.As(err, &ve)
   B. errors.Is(err, ValidationError{})
   C. var ve ValidationError; errors.As(err, ve)
   D. err.(*ValidationError) != nil

   **Answer: A.** errors.As needs a pointer to a variable of the exact type stored in the chain, here *ValidationError, so you pass &ve. A type assertion only checks the outermost error, not wrapped ones, and errors.Is compares values rather than types.

3. What does errors.Join(nil, nil) return?

   A. An error whose message is empty
   B. nil
   C. An error with the message "<nil>\n<nil>"
   D. It panics

   **Answer: B.** errors.Join discards nil arguments and returns nil if nothing is left, so you can return errors.Join(errs...) directly even when there were no problems.

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?

   A. Wrapped errors cannot be logged
   B. The handler should panic instead of logging
   C. Nothing; logging at every layer is the recommended practice
   D. The same failure appears several times in the logs; each error should be handled once, either logged or returned

   **Answer: D.** Handling means logging, retrying or responding. Doing that and also returning duplicates work and log noise. Lower layers should wrap and return; the edge (the handler) logs once and chooses the response.
