Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Idiomatic Go Error Handling: Creating, Wrapping, Inspecting, and Classifying Errors

Hello! Last lesson, you used defer to close a file and saw that closing can itself return an error. That raises a broader question: when a function fails, what should its caller receive, and how can the caller decide what to do?

In this lesson, you’ll create errors, add context without losing the original error, and classify failures using errors.Is and errors.As. You’ll also decide when not to expose an underlying error. Allow about 40 minutes for the video, two readings, and the code walkthrough.


Errors are values a caller can act on

In Go, an error is an ordinary returned value. The built-in error interface requires an Error() string method, but you rarely need to implement one yourself. A function that can fail commonly returns its result alongside an error: a nil error means success; a non-nil error means the caller should handle the failure.

For a one-off failure, fmt.Errorf makes a useful message:

func requirePort(port string) error {
	if port == "" {
		return fmt.Errorf("port is required")
	}
	return nil
}

If callers only need to report that failure, a message may be enough. If they need to recognize it and choose a different course of action, give the condition a stable identity:

var ErrMissingPort = errors.New("missing port")

func requirePort(port string) error {
	if port == "" {
		return ErrMissingPort
	}
	return nil
}

ErrMissingPort is a sentinel error: a particular error value representing a condition. The caller should check the returned err, not search its message for "missing port":

if err := requirePort(""); err != nil {
	if errors.Is(err, ErrMissingPort) {
		fmt.Println("ask for a port")
	} else {
		fmt.Println("unexpected error:", err)
	}
}

This distinction matters as code grows. Error text is for a person diagnosing a failure; an identifiable error value gives the program a dependable basis for a decision. Use sentinels selectively—usually for conditions a caller genuinely needs to distinguish.


Add context while preserving the cause

Suppose loadConfig reads a file. Returning the read error unchanged tells the caller what failed, but perhaps not what the function was trying to do. Wrapping adds that context:

func loadConfig(path string) ([]byte, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("load config %q: %w", path, err)
	}
	return data, nil
}

The %w verb creates an error that contains err. Its message includes the original error’s text, while the original error remains available for inspection. By contrast, replacing %w with %v would produce similar text but retain only the text, losing the underlying error’s identity and type.

Watch selected portions of GopherCon 2020: Working with Errors by Jonathan Amsterdam on Gopher Academy. They distinguish a readable message from an inspectable chain of errors and show the standard inspection tools.

GopherCon 2020: Working with Errors - Jonathan Amsterdam

Watch Jonathan Amsterdam's Working with Errors for a compact explanation of why wrapping preserves information that error text alone cannot.

Begin with error chains and two audiences: people read messages, while programs inspect errors. Skip ahead to percent w, then watch Is and As and inspection details. Focus on why errors.As needs the address of the variable it will fill.

A wrapped error forms an error chain: each layer may add context while retaining the error beneath it. %w does not automatically capture a stack trace; the chain records the errors your code deliberately retains.

Read the Go Blog’s Working with Errors in Go 1.13 for the underlying conventions. Although its title names the release that introduced these tools, errors.Is, errors.As, and %w are the standard tools you’ll use in current Go code.

Working with Errors in Go 1.13

Read the Go Blog's explanation of how ordinary error checks led to wrapping and chain-aware inspection.

Under “Errors before Go 1.13,” read the “Examining errors” and “Adding information” subsections, from error values. Then, under “Errors in Go 1.13,” read “The Unwrap method” and “Examining errors with Is and As,” from the error chain. Finish with “Wrapping errors with %w,” focusing on the wrapping verb. Notice which checks work through multiple layers rather than inspecting only the outermost error.

Now the caller of loadConfig can classify a missing file even though loadConfig added its own message:

_, err := loadConfig("settings.json")
switch {
case err == nil:
	fmt.Println("config loaded")
case errors.Is(err, os.ErrNotExist):
	fmt.Println("config file is missing")
default:
	fmt.Println("could not load config:", err)
}

errors.Is(err, os.ErrNotExist) looks for that condition in the chain. A direct comparison such as err == os.ErrNotExist would not recognize it once loadConfig has wrapped the error.


Inspect a type when the details matter

A missing file may require one response; for another failure, you might need information about the attempted file operation. os.ReadFile can return an *os.PathError, which carries fields such as Op and Path. Use errors.As to find that type, including when it sits inside the error returned by loadConfig:

var pathErr *os.PathError
if errors.As(err, &pathErr) {
	fmt.Printf("operation %q failed for %q\n", pathErr.Op, pathErr.Path)
}

The target is &pathErr because errors.As must assign the matching *os.PathError to your variable. Thus the argument is a pointer to a variable whose type is already a pointer. If no matching error exists, errors.As returns false; read pathErr only when it returns true.

This gives you two complementary forms of classification:

  • Use errors.Is when you care whether an error matches a known value, such as os.ErrNotExist.
  • Use errors.As when you need an error of a particular type and perhaps its fields, such as *os.PathError.

Both can inspect a wrapped chain. Neither requires parsing the error message. Ordinary err != nil remains the right check when your only question is whether the operation succeeded.

You can try the file example in your existing module. Add the loadConfig function above, importing errors, fmt, and os as needed, and call it with a path you know does not exist. Check the missing-file condition first, then use errors.As to print the operation and path. Changing the path to a readable file should leave err as nil.


Decide what callers are allowed to know

Wrapping is useful, but %w is also a design decision. If your function wraps a lower-level error, callers can write code that depends on that error’s value or type. Replacing the lower-level implementation later may then change behavior callers relied upon.

Read the “Whether to Wrap” subsection of the same Go Blog article. Its database example makes the trade-off concrete.

Working with Errors in Go 1.13

Return to the Go Blog article to examine when retaining a cause helps callers and when it exposes an implementation detail.

In “Whether to Wrap,” read from the wrapping choice. Pay particular attention to the LookupUser example: consider what a caller might start depending on if the database's error remains inspectable.

In loadConfig, exposing a file error often makes sense: the caller supplied a path and may need to respond to a missing file. But imagine a function promising only to look up an account, while privately using a database. If its callers should not know which database operation failed, wrapping a database-specific error exposes more than the function’s contract intends.

One option is to return a stable, higher-level sentinel that describes the condition callers should handle:

var ErrAccountNotFound = errors.New("account not found")

The lookup function can return ErrAccountNotFound, or wrap that sentinel with relevant account context. It need not expose the database’s particular “no rows” error. Alternatively, using %v instead of %w can retain lower-level text for diagnosis without making the lower-level error inspectable. Choose based on the behavior you intend to promise, not on which verb is shorter.

When a sentinel alone cannot carry the structured detail a caller needs, a custom error type is another option. This example records a field name and an underlying sentinel:

type FieldError struct {
	Field string
	Err   error
}

func (e *FieldError) Error() string {
	return fmt.Sprintf("field %q: %v", e.Field, e.Err)
}

func (e *FieldError) Unwrap() error {
	return e.Err
}

Error makes *FieldError satisfy Go’s error interface. Unwrap makes its Err available to chain-aware inspection. For an error constructed as &FieldError{Field: "port", Err: ErrMissingPort}, errors.Is(err, ErrMissingPort) identifies the condition, while errors.As can retrieve the *FieldError to inspect Field. You’ll study methods and receiver choices more fully later; for now, notice how the two methods give one error both a human-readable message and an inspectable structure.


Takeaways

Return a non-nil error when an operation fails, and give callers a stable sentinel or type only when they need to distinguish that failure. Wrap with %w to add context and preserve an inspectable cause; use errors.Is for a known error value and errors.As for a type. Before wrapping across a function boundary, ask whether the underlying error should become part of what callers can rely on.

Next, you’ll organize Go code into cohesive packages. The same question—what should callers be able to depend on?—will help you decide where error values and other package APIs belong.

Can't find a good explanation? Sign up and we'll make it for you

Sign up