Skip to content
beginner Phase 3 · Functions, Defers, and Error Handling

The error Interface and Error Messages

Return, wrap, and inspect errors with errors.Is and errors.As.

45m
0 problems
Topic Progress 0%

A Failure the Caller Cannot Ignore

You are building the handler for GET /users/42. The database lookup has two honest outcomes: a row, or no row. A signature like func findUser(id int64) User can only express one of them. On failure it returns the zero User — id 0, empty name — and the caller cannot distinguish "user 42 has no name" from "user 42 does not exist." The bug ships quietly and surfaces three layers away when a billing job charges the zero user.

Go makes failure a value. The convention: the last result is an error, and nil means success.

var ErrNotFound = errors.New("user not found")

func findUser(id int64) (User, error) {
	row := db.QueryRow("SELECT id, name FROM users WHERE id = ?", id)
	var u User
	if err := row.Scan(&u.ID, &u.Name); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return User{}, fmt.Errorf("find user %d: %w", id, ErrNotFound)
		}
		return User{}, fmt.Errorf("find user %d: %w", id, err)
	}
	return u, nil
}

func handler(w http.ResponseWriter, r *http.Request) {
	u, err := findUser(42)
	if err != nil {
		if errors.Is(err, ErrNotFound) {
			http.NotFound(w, r)
			return
		}
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	writeJSON(w, u)
}

Four habits are baked into that example. Check the error before touching the value — the useful result is undefined when err != nil, so reading u.ID first is reading garbage. Return early on failure so the happy path stays unindented and scannable. The caller must choose a response to every error: translate it (404), escalate it (500), or add context and pass it up. Doing nothing is the one choice that is never correct.

The fourth habit is what to do with errors you cannot use. _ = f.Close() is a deliberate decision to discard information and should read like one; f, _ := os.Open(path) in production code is usually a missing if err != nil. Discarding an error is acceptable only when you can say out loud why the failure does not matter — and if you can, the reviewer should be able to hear it.

Multiple results are what make this idiom work: the value and its failure travel together and the compiler forces the caller to receive both (Function Declarations and Multiple Returns). The rest of this topic is about what happens to that error as it climbs the call stack.

Wrapping: Context on the Way Up, Identity on the Way Down

An error that starts in a SQL driver passes through a repository, a service, and finally a handler. At each layer the error should gain one thing — the context of what the layer was doing — without losing the thing it already was. A caller later needs to ask "was this a not-found?" even though the message now reads load user 42: sql: no rows in result set.

fmt.Errorf with the %w verb wraps: it stores the original error inside the new one and makes it reachable.

// repository layer
func (r *UserRepo) Get(id int64) (User, error) {
	u, err := r.queryUser(id)
	if err != nil {
		return User{}, fmt.Errorf("repo get user %d: %w", id, err)
	}
	return u, nil
}

// service layer
func (s *Service) Profile(id int64) (Profile, error) {
	u, err := s.repo.Get(id)
	if err != nil {
		return Profile{}, fmt.Errorf("build profile: %w", err)
	}
	return Profile{User: u}, nil
}

// handler: still recognises the original failure
if errors.Is(err, ErrNotFound) {
	http.NotFound(w, r)
}

errors.Is walks the chain — the new error, whatever it wraps, whatever that wraps — and reports whether any link matches the target. Because the repository returned ErrNotFound (via %w), the handler's check succeeds three layers up. The comparison is on identity, not text, so rewording a message at any layer cannot break the caller.

When a failure carries data a caller needs, define a type instead of a sentinel:

type ValidationError struct {
	Field   string
	Message string
}

func (e *ValidationError) Error() string {
	return e.Field + ": " + e.Message
}

// at the boundary
var verr *ValidationError
if errors.As(err, &verr) {
	fmt.Fprintf(w, "field %s rejected: %s", verr.Field, verr.Message)
	return
}

errors.As is the mirror of errors.Is: instead of asking "is this error that sentinel?" it asks "does this error contain a *ValidationError?" and hands you the concrete value so you can read its fields. Use Is for identity checks against sentinels you own (ErrNotFound, ErrTimeout from context), and As when the error's payload drives the decision.

Two mechanics worth knowing. Every wrapped error implements Unwrap() error, which is how the walkers descend the chain, and errors.Unwrap(err) peels one layer for logging or debugging. Wrapping is cheap and reversible; string formatting with %v is not — it converts the error to text and severs the chain, which is the single most common way an errors.Is check mysteriously starts returning false.

Messages Are for Humans; Identity Is for Code

Two engineers see the same error value and need different things. The on-call engineer needs a message that names the operation, the subject, and the cause: find user 42: connection refused. The program needs a stable identity it can branch on. Confusing the two produces the three bugs that dominate Go code review.

Matching on message text. This compiles and breaks silently:

if strings.Contains(err.Error(), "no rows") {
	return ErrNotFound
}

The moment a driver upgrades its wording, a colleague rephrases the message, or a wrapping layer prepends context, the match fails and a 404 becomes a 500. Identity checks belong to errors.Is and errors.As, which inspect the error chain instead of its text. Reserve err.Error() for logs and human-facing output — never for control flow.

Logging the error and then returning it. The seductive version:

if err != nil {
	log.Printf("failed to load user %d: %v", id, err)
	return err
}

Now every layer records the same failure. The log shows the error five times with progressively vaguer context, and the operator who greps for it finds a wall of duplicates instead of one line with a stack. Pick one: return the error with context added and let the top of the process log it once, or handle it locally and do not return it. Logging and returning is double reporting.

Silently converting errors the caller could have used. This loses information on the way out:

u, err := repo.Get(id)
if err != nil {
	return User{}, errors.New("user lookup failed") // original error: gone
}

The caller can no longer tell not-found from database-down. If you must reword, wrap: fmt.Errorf("get user %d: %w", id, err) keeps the message human and the chain intact.

The message style rules the community converged on are short and boring: start lowercase, no trailing period, no capitalised acronyms mid-sentence, state what was being attempted and what subject it involved. Messages should never repeat context every layer already adds — a message written as open config: ... at the file layer and load settings: ... at the service layer is correct; a message that restates both at every layer is noise. What matters for code is the sentinel or type underneath the message. What matters for the message is that at 3 a.m. it answers "which operation, which subject, which cause" in one line.

Edges, Loops, and Cleanup: Where Error Handling Gets Hard

The clean two-line if err != nil pattern covers most code. Three situations do not fit it, and each has an established answer.

Several independent failures in one operation. A batch write that runs four inserts should report all four failures, not just the first:

var errs []error
for _, row := range rows {
	if err := insert(row); err != nil {
		errs = append(errs, fmt.Errorf("row %d: %w", row.ID, err))
	}
}
if err := errors.Join(errs...); err != nil {
	return fmt.Errorf("batch insert: %w", err)
}

errors.Join builds one error whose Error() prints every message and whose chain supports errors.Is against any of the inputs. Use it when partial success is real information; use first-error-wins when one failure invalidates the rest of the work.

Cleanup that can fail. defer f.Close() discards a write error that means data was lost. The fix combines defers with named results — deferred code runs before the caller sees the return value, so it can replace a nil with a failure (defer and the Deferred Call Stack):

func writeReport(path string, data []byte) (err error) {
	f, err := os.Create(path)
	if err != nil {
		return err
	}
	defer func() {
		if cerr := f.Close(); cerr != nil && err == nil {
			err = fmt.Errorf("close %s: %w", path, cerr)
		}
	}()
	_, err = f.Write(data)
	return err
}

Conditions that are not failures. Not every bad outcome is an error value. A cache miss, an empty search result, and the end of an iteration are states of the world — model them with booleans, slices, or io.EOF. An error should mean "something went wrong and the caller must decide what to do." When a condition means "this code can never run" rather than "this request failed," you have left error-handling territory and entered panic, recover, and When to Fail.

For review, the checklist is small: is the error checked before the value is used, does every layer either handle or wrap (never both log and return), does the branch use errors.Is/errors.As rather than text, and does the top level — HTTP handler, CLI main, job runner — produce exactly one log line per failure. Errors in Go are ordinary values, which is precisely why they compose: you can store them, wrap them, compare them, and test them without a framework.

Quiz

1. A repository wraps a sentinel with `fmt.Errorf("get row: %w", ErrNotFound)`, and a service wraps that again with `fmt.Errorf("load user: %w", err)`. What does `errors.Is(err, ErrNotFound)` return at the top?

Question 1 options

2. A colleague writes `if strings.Contains(err.Error(), "no rows") { return ErrNotFound }` instead of `errors.Is`. What is the real risk?

Question 2 options

3. Code in three layers each does `log.Printf("failed: %v", err); return err`. What problem does the top level experience?

Question 3 options

4. A validation failure carries `Field` and `Message` fields, and a handler needs those fields to build its JSON response. Which check is correct?

Question 4 options

Flashcards

Question

What must a type provide to satisfy Go's `error` interface, and can you define your own error types?

Answer

A single method `Error() string`. Any type — struct, pointer, or named string — with that method satisfies the interface automatically, which is how sentinels, typed errors like *ValidationError, and wrapped errors all coexist as plain values.

Question

When do you reach for errors.Is versus errors.As?

Answer

errors.Is answers an identity question against a sentinel you own: is this the ErrNotFound that the repository produced, anywhere in the chain? errors.As extracts a concrete error type when the failure carries fields — a ValidationError's Field, a *url.Error's timeout — that the boundary needs to read.

Question

What does `%w` in fmt.Errorf do that `%v` does not?

Answer

%w stores the original error inside the new one so the chain stays walkable — errors.Is, errors.As, and errors.Unwrap can still reach it. %v converts to text at formatting time, severing the chain; any later identity check against the original error returns false.

Question

Why is checking `strings.Contains(err.Error(), "timeout")` a bug waiting to happen?

Answer

It couples control flow to wording that any layer, driver upgrade, or translation can change. Timeouts should be identified by identity — errors.Is(err, context.DeadlineExceeded) or a sentinel you define — so the branch survives reworded messages and added context.

Question

What is wrong with logging an error and then returning it from the same function?

Answer

It double-reports: every layer on the stack logs the same failure with overlapping context, and the operator loses the single authoritative line. Either return it with wrapped context and log once at the process boundary, or handle it locally and do not return it.

Revision Notes

Key Takeaways

  • 1. Error is an interface with one method (Error() string), so sentinels, typed errors, and wraps are all ordinary values
  • 2. The last result is the error; check it before using the value, return early, and never discard without saying why
  • 3. %w preserves the chain for errors.Is/errors.As; %v destroys it by converting to text
  • 4. Messages serve humans, identity serves code: match with Is/As, never with strings.Contains on err.Error()
  • 5. Each layer either handles or wraps — logging and returning the same error double-reports the failure

Interview Tips

  • • "Walk me through your error handling in Go" — say the chain out loud: check at the call site, wrap once per layer with %w, branch at the boundary with Is/As, log once at the top
  • • "How would you handle a not-found across three layers?" — sentinel returned by the repository, %w at each hop, errors.Is in the HTTP handler to choose 404 over 500
  • • "What's the difference between errors.Is and errors.As?" — identity versus extraction; follow with the example of a ValidationError carrying a field name
  • • "When would you log an error instead of returning it?" — only where the chain ends (handler, main, job runner); be ready to explain why logging mid-stack duplicates reports

Cheat Sheet

The error Interface and Error Messages

Pick the inspection tool:

Situation Tool Example
Identity check against a sentinel errors.Is errors.Is(err, ErrNotFound)
Failure carries fields you must read errors.As errors.As(err, &verr) where var verr *ValidationError
Add context, keep the chain fmt.Errorf %w fmt.Errorf("get user %d: %w", id, err)
Merge independent failures errors.Join errors.Join(errs...)
Peel one layer for debugging errors.Unwrap errors.Unwrap(err)

Layer discipline: handle the error locally, or wrap once with what THIS layer was doing and return it — never log and return the same error. The process boundary logs exactly once.

Message style: lowercase start, no trailing period, name operation + subject + cause (find user 42: connection refused). Messages are for humans; sentinels and types are for branches.

Code template:

var ErrExpired = errors.New("token expired")

func verify(tok string) (Claims, error) {
	c, err := parse(tok)
	if err != nil {
		return Claims{}, fmt.Errorf("verify token: %w", err)
	}
	if c.Expiry.Before(time.Now()) {
		return Claims{}, ErrExpired
	}
	return c, nil
}

Continue Learning

Before You Start

  • No prerequisites listed.

Related Reading