Chapter 11 ended with the TaskStore interface returning a simple boolean to indicate if a task existed. That works for a basic map, but real applications need more detail when things fail. This chapter adds the other half of that design. Instead of Get(id int) (Task, bool), the store now exposes Find(id int) (Task, error). We also introduce a package-level sentinel value called ErrTaskNotFound, giving the future HTTP layer something concrete to map to a 404 Not Found response.
Go error handling relies on one central idea: errors are just values. The error type is a simple interface with a single method. Any type that implements Error() string satisfies it. Functions return errors as a second return value, and callers check them explicitly. There is no hidden exception mechanism.

This chapter covers four concrete patterns you will use constantly:
errors.New and fmt.Errorf%w and unwrapping them with errors.Is and errors.AsCheck out the finish branch:
bashgit checkout 12-errors-finish
Sentinel error. A package-level error value, declared once and compared by identity. var ErrTaskNotFound = errors.New("task not found") is a sentinel. Callers check errors.Is(err, ErrTaskNotFound) instead of comparing strings.
Error wrapping. Using fmt.Errorf("load task %d: %w", id, err) to add context to an error while preserving the original underneath. The %w verb records the inner error so errors.Is and errors.As can find it later even through multiple layers of wrapping.
Custom error type. A struct with an Error() string method. Because error is just an interface, any struct that has that method satisfies it. A struct lets the error carry structured data, like which field failed validation.
Most languages use exceptions. A function throws, the runtime unwinds the call stack, and a catch block handles it somewhere up the chain. The failure path is implicit.
Go makes failure paths explicit. Every function that can fail says so in its signature, like (Task, error). The caller is forced to acknowledge the result. You can choose to propagate it, wrap it, inspect it, or handle it right there. You cannot accidentally ignore it without the linter noticing.
The trade-off is verbosity. You will write if err != nil { return err } frequently. The upside is that you always know exactly which functions can fail and where errors are handled. In a large codebase, that clarity is highly valuable.
error interfaceThis is the entire error interface from the Go standard library:
gotype error interface {Error() string}
Any type with an Error() string method satisfies this interface. errors.New returns one. fmt.Errorf returns one. Your own custom struct can return one. Under the hood, they all implement this exact same interface.
The two most common ways to create an error are errors.New and fmt.Errorf:
govar ErrTaskNotFound = errors.New("task not found")
goreturn Task{}, fmt.Errorf("load task %d: %w", id, err)
errors.New creates a simple error with a fixed message. You generally use it for sentinel values at the package level.
fmt.Errorf creates an error with a formatted message. When you use the %w verb instead of %v, the formatted error wraps the inner error. This allows callers to still find the original error using errors.Is.
The standard pattern for propagating an error up the call stack looks like this:
goif err != nil {return Task{}, fmt.Errorf("load task %d: %w", id, err)}
You have seen this pattern since Chapter 7. Now we can look closer at how it operates.
errors.IsErrTaskNotFound is declared at the package level in store.go:
go// store.govar ErrTaskNotFound = errors.New("task not found")
InMemoryStore.Find returns this sentinel when a task ID is missing:
go// store.gofunc (s *InMemoryStore) Find(id int) (Task, error) {t, ok := s.tasks[id]if !ok {return Task{}, ErrTaskNotFound}return t, nil}
The caller in errorsDemo wraps the error with additional context, then inspects it:
go// errorsdemo.goif _, err := loadTask(99); err != nil {fmt.Println("loadTask(99) failed:", err)fmt.Println(" errors.Is(err, ErrTaskNotFound):", errors.Is(err, ErrTaskNotFound))}
The loadTask function wraps the error before returning it:
go// errorsdemo.gofunc loadTask(id int) (Task, error) {store := NewInMemoryStore()t, err := store.Find(id)if err != nil {return Task{}, fmt.Errorf("load task %d: %w", id, err)}return t, nil}
The output confirms that errors.Is finds the sentinel even though the error has been wrapped:
loadTask(99) failed: load task 99: task not found
errors.Is(err, ErrTaskNotFound): true
This is the architectural tie-in for our future 404 responses. In Chapter 21, the HTTP handler calls store.Find, checks errors.Is(err, store.ErrTaskNotFound), and returns a 404 response. The handler does not need to know how many wrapping layers sit between it and the sentinel. errors.Is unwraps the chain and reports the match.

errors.Is vs errors.Aserrors.Is answers the question: is there a specific error value anywhere in this chain? errors.As answers a different question: is there an error of a specific type anywhere in this chain, and if so, can I have it?
Use errors.Is for sentinel values. Use errors.As for custom error types.
errors.AsValidationError carries the specific field name and the reason for the failure, rather than just a plain string:
go// errorsdemo.gotype ValidationError struct {Field stringMsg string}func (e *ValidationError) Error() string {return fmt.Sprintf("%s: %s", e.Field, e.Msg)}
The validateTitle function returns one of these custom errors:
go// errorsdemo.gofunc validateTitle(title string) error {if title == "" {return &ValidationError{Field: "title", Msg: "must not be empty"}}return nil}
The caller then uses errors.As to pull the struct back out:
go// errorsdemo.goerr := validateTitle("")var ve *ValidationErrorif errors.As(err, &ve) {fmt.Printf("validation failed on field %q: %s\n", ve.Field, ve.Msg)}
errors.As searches the error chain for a value assignable to *ValidationError. If it finds one, it writes that value into ve and returns true. You then have access to the original struct with its Field and Msg fields.
In Chapter 22, the validation layer will return structured errors exactly like this. The HTTP handler will use errors.As to produce a JSON response telling the client exactly which field failed and why.
TaskStore interface after this chapterAdding Find to the interface is a small change with a large consequence. The full interface in store.go now reads:
go// store.gotype TaskStore interface {Add(t Task) TaskGet(id int) (Task, bool)Find(id int) (Task, error)All() []Task}
Get uses the comma-ok idiom from Chapter 10, returning (Task, bool). Find uses the error idiom, returning (Task, error) and signaling absence with ErrTaskNotFound.
Both exist on the interface right now because the Chapter 11 demo uses Get, and we want to keep the old demo working. In a real API, you would pick one pattern and use it consistently. The HTTP handlers in Chapter 21 will use Find.
LoggingStore also gets the new Find method:
go// store.gofunc (s *LoggingStore) Find(id int) (Task, error) {_, _ = fmt.Fprintf(s.out, " [log] Find(%d)\n", id)return s.inner.Find(id)}
The wrapper logs the call and delegates to the inner store, exactly as Get and Add do. Because both stores satisfy the updated interface, all the earlier demos still compile and run unchanged.
Go has panic as well as errors. They serve different purposes.
An error is for an expected failure, like a file not found, a missing task, or invalid input. The function returns an error and the caller handles it.
A panic is for an unexpected failure, like a nil pointer dereference, indexing out of bounds, or a type assertion with the wrong type. A panic unwinds the goroutine's stack and kills the program unless something recovers it.
A good rule of thumb is that if a caller can reasonably handle a failure, you should return an error. If a failure means the code has a bug that nobody should be expected to handle at runtime, let it panic.
The safeDivide function in errorsdemo.go demonstrates recover:
go// errorsdemo.gofunc safeDivide(a, b int) (result int, err error) {defer func() {if r := recover(); r != nil {err = fmt.Errorf("recovered from panic: %v", r)}}()return a / b, nil}
When b is zero, Go panics with runtime error: integer divide by zero. The deferred function calls recover(), which stops the panic and returns the value that was panicking. The named return variable err is then set, and the function returns normally.
This is just a demonstration. In production code, dividing by zero is a bug you fix, not a condition you recover from at runtime. The real use of recover in this course is the recovery middleware in Chapter 32. That middleware catches unexpected panics in HTTP handlers and returns a 500 Internal Server Error instead of killing the entire server process.
Swallowing the error. Using _ to discard the error return throws away valuable information.
goresult, _ = doSomething() // don't do this
The linter will flag this. There are narrow cases where ignoring an error is intentional, like the _, _ = fmt.Fprintf(...) in LoggingStore where logging to standard output is genuinely best-effort. Those are the exception, not the rule.
Shadowing err inside a block. If you declare a new err variable using := inside an if or for block, the outer err remains unchanged.
goerr := doFirst()if condition {err := doSecond() // this err is scoped to this if block only_ = err}// outer err is still doFirst()'s result
Over-wrapping. Wrapping every error with a new message is not always helpful. If loadTask wraps, the function calling loadTask wraps, and the function calling that wraps, you end up with a redundant chain like api: service: load task 99: task not found. Wrap an error when you are adding genuinely useful context. Propagate it bare when the existing message is already clear.
Chapter 13 closes this section with goroutines and channels. Those concurrency basics are what will keep our in-memory task store safe once the server handles many requests at the same time.