Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Go  Module 7 – Errors: values, wrapping, Is, As and AsType

Wrapping errors: %w, Is, As, AsType and Join

Wrap Go errors with %w, find them again with errors.Is, errors.As and Go 1.26's errors.AsType, and report several failures at once with errors.Join.

  • Intermediate
  • 15 minutes
  • Examples run with Go 1.27.1 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 6 of 15 minutes free
Free: %w keeps the error inside; %v keeps only its words · errors.Is looks for a value; errors.As and errors.AsType look for a type · errors.Join reports every failure at once. With a pass: 2 in-depth parts and 3 interview answers, about 9 minutes.

What you will learn

  • Wrap an error with %w, and explain what errors.Is and errors.Unwrap can still find that %v hides
  • Extract an error of a given type from a chain with errors.As and with errors.AsType
  • Combine independent failures with errors.Join, and test each of them with errors.Is
  • Predict which error errors.Is and errors.AsType find first in a tree of wrapped errors
  • Decide when a wrapped error becomes part of your API, and fix the wrapping mistakes go vet reports

Before you start

What you’ll learn · Before you start

What you will learn

  • Wrap an error with %w, and explain what errors.Is and errors.Unwrap can still find that %v hides
  • Extract an error of a given type from a chain with errors.As and with errors.AsType
  • Combine independent failures with errors.Join, and test each of them with errors.Is
  • Predict which error errors.Is and errors.AsType find first in a tree of wrapped errors
  • Decide when a wrapped error becomes part of your API, and fix the wrapping mistakes go vet reports

Before you start

On this page

A config loader fails with load config: open app.toml: no such file or directory, and its caller should fall back to default settings when the file is simply missing. errors.Is(err, fs.ErrNotExist) answers that question, but only if the loader kept the original error when it added its context: with %w it did, with %v it did not, and the two messages read exactly the same.

You will wrap errors with %w, find them again by value with errors.Is and by type with errors.As and errors.AsType, Go 1.26’s generic form, and report several failures at once with errors.Join. The programs ran on Go 1.27.1; under each one is exactly what it printed.

%w keeps the error inside; %v keeps only its words

wrap_or_flatten.go gives one failure its context twice: loadWrapped uses %w and loadFlattened uses %v.

wrap_or_flatten.go: the same context, added with %w and with %v Go · wrap_or_flatten.go
// wrap_or_flatten.go: the same failure given context twice, once with %w and once with %v.
package main

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
)

func loadWrapped(name string) error {
	if _, err := os.ReadFile(name); err != nil {
		return fmt.Errorf("load config: %w", err)
	}
	return nil
}

func loadFlattened(name string) error {
	if _, err := os.ReadFile(name); err != nil {
		return fmt.Errorf("load config: %v", err)
	}
	return nil
}

func main() {
	for _, err := range []error{loadWrapped("app.toml"), loadFlattened("app.toml")} {
		fmt.Printf("%v (%T)\n", err, err)
		fmt.Println("  errors.Is(err, fs.ErrNotExist):", errors.Is(err, fs.ErrNotExist))
		inner := errors.Unwrap(err)
		fmt.Printf("  errors.Unwrap(err): %v (%T)\n", inner, inner)
	}
}

Output

load config: open app.toml: no such file or directory (*fmt.wrapError)
  errors.Is(err, fs.ErrNotExist): true
  errors.Unwrap(err): open app.toml: no such file or directory (*fs.PathError)
load config: open app.toml: no such file or directory (*errors.errorString)
  errors.Is(err, fs.ErrNotExist): false
  errors.Unwrap(err): <nil> (<nil>)

Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run wrap_or_flatten.go

Both lines read load config: open app.toml: no such file or directory, yet only the first error is a wrapped error: An error that keeps another error inside it, as the result of fmt.Errorf with the %w verb does. Its Unwrap method returns the error it keeps, so errors.Is, errors.As and errors.AsType can still find that error., a *fmt.wrapError. The documentation of fmt.Errorf says that with a %w verb the result has an Unwrap method that returns the operand, so errors.Unwrap gave back the *fs.PathError of os.ReadFile and errors.Is could keep looking. The %v version is a plain error made from the text, with nothing to unwrap, so errors.Is said false. Step through both searches:

Interactive · step by step What errors.Is finds in a wrapped and in a flattened error Step through errors.Is(err, fs.ErrNotExist) on the two errors of wrap_or_flatten.go, the wrapped one on the left.
What errors.Is finds in a wrapped and in a flattened error, step 5 of 5. Showing load config: %w, open app.toml: …, the system's error (matches fs.ErrNotExist), load config: %v (errors.Is: false). In focus: load config: %v.Unwrap()Unwrap()load config: %w*fmt.wrapErroropen app.toml: …*fs.PathErrorthe system's errormatches fs.ErrNotExistload config: %verrors.Is: false

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. Start at the outer error. errors.Is first compares the error it was given. The outer error of the wrapped version is not fs.ErrNotExist, so errors.Is asks it for the error it keeps.
  2. The *fs.PathError. os.ReadFile failed with a *fs.PathError that holds the operation, the path and the cause. It is not fs.ErrNotExist either, so errors.Is unwraps once more.
  3. The system's error matches. The innermost error is the one the operating system returned for a missing file, and errors.Is accepts it as fs.ErrNotExist. errors.Is returns true.
  4. The flattened error. The %v version printed the same words, but fmt.Errorf made a new error from the text alone. It is not fs.ErrNotExist, and it keeps no other error to look at.
  5. Nothing left to search. With no Unwrap method there is nowhere to go, so errors.Is returns false. Both messages read the same; only the wrapped one can still be asked what it is made of.

errors.Is compares the error it is given, then each error it reaches through Unwrap, with the target. That is why one %w in each layer is enough to keep a sentinel error: An error made once and kept in a package-level variable, such as io.EOF or ErrNotFound, so that callers can recognise that one failure. They test for it with errors.Is, which still finds it after context was added with %w. More in “Errors are values: returning and checking them” such as fs.ErrNotExist findable from the top. Wrap with %w when callers may need the original error; use %v when the original is a detail they should not depend on.

Checkpoint

Check: Wrapping with %w, and what %v loses

  1. Question 1 of 2 A loader returns fmt.Errorf("load config: %v", err) when its file is missing, and the caller tests errors.Is(err, fs.ErrNotExist). What does the caller get?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: false, because %v put only the text of the original error into the new one

    Both versions print the same words, but only the one built with %w keeps the original error for errors.Is to find.

  2. Question 2 of 2 What does errors.Unwrap return for an error made by fmt.Errorf("load config: %w", err)?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: err, the error the %w verb formatted

    Each %w adds one level, and errors.Unwrap takes away exactly one.

errors.Is looks for a value; errors.As and errors.AsType look for a type

Some errors carry data. A text-message gateway that refuses a one-time password because the day’s quota is used up returns a *QuotaError with the count and the limit, and login wraps it twice on the way up:

quota.go: a *QuotaError, wrapped twice, found three ways Go · quota.go
// quota.go: an error type with fields, wrapped twice, found again with errors.As and errors.AsType.
package main

import (
	"errors"
	"fmt"
)

// QuotaError reports a daily limit of text messages that a send would pass.
type QuotaError struct {
	Used, Limit int
}

func (e *QuotaError) Error() string {
	return fmt.Sprintf("quota: %d of %d messages used", e.Used, e.Limit)
}

func send(used int) error {
	if used >= 1000 {
		return &QuotaError{Used: used, Limit: 1000}
	}
	return nil
}

func sendOTP(phone string, used int) error {
	if err := send(used); err != nil {
		return fmt.Errorf("send OTP to %s: %w", phone, err)
	}
	return nil
}

func login(phone string, used int) error {
	if err := sendOTP(phone, used); err != nil {
		return fmt.Errorf("login: %w", err)
	}
	return nil
}

func main() {
	err := login("98xxxxxx10", 1001)
	fmt.Println(err)

	_, ok := err.(*QuotaError)
	fmt.Println("type assertion err.(*QuotaError):", ok)

	var qe *QuotaError
	if errors.As(err, &qe) {
		fmt.Println("errors.As:", qe.Used-qe.Limit, "over the limit")
	}

	if qe, ok := errors.AsType[*QuotaError](err); ok {
		fmt.Println("errors.AsType:", qe.Used-qe.Limit, "over the limit")
	}

	fmt.Println("no failure:", login("98xxxxxx10", 12))
}

Output

login: send OTP to 98xxxxxx10: quota: 1001 of 1000 messages used
type assertion err.(*QuotaError): false
errors.As: 1 over the limit
errors.AsType: 1 over the limit
no failure: <nil>

Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run quota.go

The type assertion: An expression x.(T) that asserts that the interface value x holds a T and gives it back typed as T. With one result it panics on a mismatch; v, ok := x.(T) reports false and the zero value instead. More in “Type assertions and type switches” err.(*QuotaError) tests only the outer error, so it said false. errors.As walks the chain as errors.Is does but compares types: given a pointer to a *QuotaError variable, it stored the first match there and returned true. errors.AsType, added in Go 1.26, makes the same search and returns the match with a bool, so the variable lives inside the if. Both found the error 1 over the limit. The five tests side by side:

Test Looks inside wrapped errors Gives back
err == ErrX no: the outer error only whether it is this value
errors.Is(err, ErrX) yes whether this value is anywhere
err.(*T) no: the outer error only the *T and ok
errors.As(err, &t) yes true, with the first *T stored in t
errors.AsType[*T](err) yes the first *T and ok

Go 1.26: errors.AsType

The release notes call errors.AsType a generic version of errors.As that is type-safe, faster and in most cases easier to use, and the package documentation now says to prefer it for most uses. errors.As remains for code that must build with older releases.

Checkpoint

Check: errors.Is for values, As and AsType for types

  1. Question 1 of 2 In quota.go, why did err.(*QuotaError) give false while errors.As found the quota error?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The type assertion tests only the outer error, which login and sendOTP had wrapped twice

    Assertions and == stop at the outer error; errors.Is, errors.As and errors.AsType look through every %w.

  2. Question 2 of 2 Which call extracts the quota error with the generic function that Go 1.26 added?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: qe, ok := errors.AsType[*QuotaError](err)

    errors.AsType[*T](err) returns the first *T it finds in the chain and true, or the zero value and false.

errors.Join reports every failure at once

A signup form can be wrong in several ways, and a user who fixes one field only to hear about the next one is badly served. errors.Join combines errors into one. It drops nil values and returns nil when every value is nil, so a validator can keep one error per field and return the join:

join.go: a signup form with two bad fields Go · join.go
// join.go: every failure of a signup form returned at once with errors.Join, and each found with errors.Is.
package main

import (
	"errors"
	"fmt"
)

var (
	ErrNameMissing = errors.New("name is missing")
	ErrPhoneDigits = errors.New("phone needs 10 digits")
	ErrPINDigits   = errors.New("PIN code needs 6 digits")
)

type Signup struct {
	Name, Phone, PIN string
}

// validate checks every field and returns all the failures it found, or nil.
func validate(s Signup) error {
	var name, phone, pin error
	if s.Name == "" {
		name = ErrNameMissing
	}
	if len(s.Phone) != 10 {
		phone = fmt.Errorf("phone %q: %w", s.Phone, ErrPhoneDigits)
	}
	if len(s.PIN) != 6 {
		pin = fmt.Errorf("PIN code %q: %w", s.PIN, ErrPINDigits)
	}
	return errors.Join(name, phone, pin)
}

func main() {
	err := validate(Signup{Name: "", Phone: "98765", PIN: "560001"})
	fmt.Println(err)
	fmt.Println("name missing:", errors.Is(err, ErrNameMissing))
	fmt.Println("phone digits:", errors.Is(err, ErrPhoneDigits))
	fmt.Println("PIN digits:  ", errors.Is(err, ErrPINDigits))

	ok := validate(Signup{Name: "Asha", Phone: "9876543210", PIN: "560001"})
	fmt.Println("a valid form:", ok, ok == nil)
}

Output

name is missing
phone "98765": phone needs 10 digits
name missing: true
phone digits: true
PIN digits:   false
a valid form: <nil> true

Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run join.go

The message has one line per failure, the name and then the phone, and errors.Is found ErrNameMissing and ErrPhoneDigits inside the joined error, while ErrPINDigits, which never failed, gave false. The valid form returned nil, so the caller’s usual err != nil test still works. fmt.Errorf can wrap several errors too, with more than one %w in its format: fmt.Errorf("notify: %w; %w", smsErr, mailErr) keeps both. The tree such errors form is searched in a fixed order, which decides what errors.AsType returns when two errors match. go vet reports two wrapping mistakes that compile cleanly: a pointer wrapped where the error type is a value, and a target that errors.As cannot fill.

Key idea

Add context with %w so the original error stays findable, test for a value with errors.Is and for a type with errors.AsType, and return errors.Join when several independent things failed.

Checkpoint

Check: Several failures in one error with errors.Join

  1. Question 1 of 2 A validator checks three fields, all of them valid, and returns errors.Join(nameErr, phoneErr, pinErr) with all three nil. What does it return?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: nil

    Because Join returns nil when nothing failed, callers can keep their usual err != nil test.

  2. Question 2 of 2 validate in join.go found an empty name and a five-digit phone. Which of these are true of the joined error? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • errors.Is(err, ErrNameMissing)
    • errors.Is(err, ErrPhoneDigits)

    A joined error holds each non-nil error passed to it, and errors.Is tests each of them.

Error trees: the search order, and when wrapping becomes your API Pro: opens with a Pro, Premium or Ultimate pass

Several %w verbs and errors.Join turn a chain into a tree, and the errors package searches it in a fixed order. This part prints a tree of seven errors in that order, shows which error wins when two match, and reproduces what goes wrong when a library’s error leaks through your own API.

In this part:

  • Seven errors, printed in the order errors.Is searches them
  • Wrapping a library’s error makes it part of your API: the failure reproduced

1 diagram · 2 runnable examples (Go) · about 7 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Wrapping mistakes reproduced, and what AsType saves, measured Pro: opens with a Pro, Premium or Ultimate pass

Two mistakes compile and fail only on the day an error must be found: a pointer wrapped where the error type is a value, and a target that errors.As cannot fill. This part reproduces both, shows what go vet reports for each, and measures the allocation that errors.As makes and errors.AsType does not.

In this part:

  • A wrapped pointer, reproduced: errors.Is and AsType miss it
  • errors.As against errors.AsType: a compile error instead of a panic, and no allocation
  • AsType in a library: go build passes, and go test fails until the go line says 1.26

1 diagram · 8 runnable examples (Go, Python, oldquota/quota.go, oldquota/quota_test.go) · about 7 minutes

Opening this part with your pass…Pro part. It opens with the same pass as the part above.

Practise

Questions on wrapping and inspecting errors come one at a time, starting from the idea you have answered least well, and each option is explained once you pick it:

Practice that adapts to you

Practise this lesson

7 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 7 Which fmt.Errorf verb makes the new error keep the error it formats?

    Choose one answer.

    Show the answer: Question 1 of 7

    Answer: %w

    Only %w wraps; every other verb turns the error into text.

  2. Question 2 of 7 A payment package returns fmt.Errorf("charge: %v", err) for every gateway error, and its callers now need to retry gateway timeouts. What should change?

    Choose one answer.

    Show the answer: Question 2 of 7

    Answer: Return the timeout with %w, so callers can test errors.Is(err, ErrGatewayTimeout)

    Wrap with %w the errors that callers need to recognise.

  3. Question 3 of 7 Which function finds an error of a given type, such as *QuotaError, anywhere in a chain and returns it?

    Choose one answer.

    Show the answer: Question 3 of 7

    Answer: errors.AsType

    Is answers "is this value there?"; As and AsType answer "is an error of this type there, and which is it?".

  4. Question 4 of 7 The gateway exports both ErrQuota and a *QuotaError type with Used and Limit. A login screen must say how many messages over the limit the user is. Which test does it need?

    Choose one answer.

    Show the answer: Question 4 of 7

    Answer: errors.AsType[*QuotaError](err), to read Used and Limit

    Choose errors.Is when the kind of failure is enough, and errors.AsType when you need its data.

  5. Question 5 of 7 How does an error made by errors.Join format its message?

    Choose one answer.

    Show the answer: Question 5 of 7

    Answer: Each non-nil error's text, in order, with a newline between them

    A joined error reads as one line per failure.

  6. Question 6 of 7 join.go validates a form with an empty name, the phone 98765 and the PIN code 560001, then a valid form. What does it print?

    What does this program print? Choose one answer.

    // join.go: every failure of a signup form returned at once with errors.Join, and each found with errors.Is.
    package main
    
    import (
    	"errors"
    	"fmt"
    )
    
    var (
    	ErrNameMissing = errors.New("name is missing")
    	ErrPhoneDigits = errors.New("phone needs 10 digits")
    	ErrPINDigits   = errors.New("PIN code needs 6 digits")
    )
    
    type Signup struct {
    	Name, Phone, PIN string
    }
    
    // validate checks every field and returns all the failures it found, or nil.
    func validate(s Signup) error {
    	var name, phone, pin error
    	if s.Name == "" {
    		name = ErrNameMissing
    	}
    	if len(s.Phone) != 10 {
    		phone = fmt.Errorf("phone %q: %w", s.Phone, ErrPhoneDigits)
    	}
    	if len(s.PIN) != 6 {
    		pin = fmt.Errorf("PIN code %q: %w", s.PIN, ErrPINDigits)
    	}
    	return errors.Join(name, phone, pin)
    }
    
    func main() {
    	err := validate(Signup{Name: "", Phone: "98765", PIN: "560001"})
    	fmt.Println(err)
    	fmt.Println("name missing:", errors.Is(err, ErrNameMissing))
    	fmt.Println("phone digits:", errors.Is(err, ErrPhoneDigits))
    	fmt.Println("PIN digits:  ", errors.Is(err, ErrPINDigits))
    
    	ok := validate(Signup{Name: "Asha", Phone: "9876543210", PIN: "560001"})
    	fmt.Println("a valid form:", ok, ok == nil)
    }
    Show the answer: Question 6 of 7

    Answer: it prints

    name is missing
    phone "98765": phone needs 10 digits
    name missing: true
    phone digits: true
    PIN digits:   false
    a valid form: <nil> true

    errors.Join drops nil values, writes each remaining error on its own line, lets errors.Is reach every part, and returns nil when nothing failed.

  7. Question 7 of 7 A bulk upload of three files must report every file that failed, and the caller must still notice if one of them failed with ErrDiskFull. What should the upload return?

    Choose one answer.

    Show the answer: Question 7 of 7

    Answer: errors.Join of one error per failed file, each made with fmt.Errorf("upload %s: %w", name, err)

    Join keeps every failure and every wrapped value; each part should carry its own context.

Interview questions

Warm-up (fresher): What is the difference between %w and %v when fmt.Errorf adds context to an error?

Both put the error’s text into the new message. With %w the new error also keeps the original: it has an Unwrap method that returns it, so errors.Is, errors.As and errors.AsType can still find it. With %v only the text is kept, and callers can no longer test for the original error. Use %w when callers may need to recognise the error, and %v when it is a detail they should not depend on.

Three longer questions of the module bank build on this lesson:

  1. Mid to senior

    A repository layer wraps every database error with %w. A year later, handlers test errors.Is(err, sql.ErrNoRows) and check driver error types in a dozen places, and the team wants to serve hot reads from a cache. What went wrong in the error design, and how do you fix it without breaking callers?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to mid

    Design ValidateSignup(form) error so that a form with three bad fields reports all three at once, and the HTTP layer can answer with each field's name and rule as JSON. Which standard-library tools do you use, and how does the handler read the fields back out of the error?

    Model answer, Pro. First step and follow-ups

  3. Mid to senior

    errors.AsType[TimeoutErr](err) now returns false, although the log shows the text of a TimeoutErr. TimeoutErr's Error method has a value receiver, and a recent change touched the code that returns it. What do you look for, and how do you make this mistake hard to repeat?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • %w keeps the error it formats inside the new error, so errors.Is and errors.Unwrap can still reach it; %v keeps only its words.
  • errors.Is looks for a value anywhere in the chain; errors.As and errors.AsType look for a type, and AsType returns the match itself.
  • errors.Join returns all the failures as one error, drops nil values, returns nil when nothing failed, and errors.Is still finds each part.

Terms in this lesson

wrapped error
An error that keeps another error inside it, as the result of fmt.Errorf with the %w verb does. Its Unwrap method returns the error it keeps, so errors.Is, errors.As and errors.AsType can still find that error.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.