1. Check errors by meaning, not by err.Error()
When you write your first programs, it’s very tempting to do this: if the error text contains the word "invalid", then it’s an “input error”; otherwise it’s an “internal error”. And it works… right up until the first refactor, a wording change, localization, a library update, or just the day someone adds a period at the end of the message.
In Go, the string err.Error() is first and foremost a message for a human, not a contract for a program. If you start making decisions based on strings.Contains(err.Error(), "..."), you’re signing up for endless pain: “why does everything work on Petya’s machine, but Masha gets a different error?” (spoiler: because Petya updated a dependency).
Sometimes you still print the string (to logs, to the user, in a CLI), but branching your logic is better done not on the string, but on checkable entities. That’s exactly what errors.Is and errors.As are for in Go.
2. errors.Is: checking an error for a specific cause
errors.Is(err, target) answers the question: “Is err (or any cause inside its chain) that exact error target?” This matters because after wrapping, comparing err == target almost always stops working: what you return outward is a new wrapper error, not the original one.
The key idea: errors.Is walks the cause chain itself, as if you were manually calling errors.Unwrap in a loop, but without extra boilerplate and without mistakes.
Mini demo: err == ErrX breaks after wrapping
Let’s imagine a sentinel error: a value created in advance:
package main
import (
"errors"
"fmt"
)
var ErrBadInput = errors.New("bad input")
func main() {
err := fmt.Errorf("parse: %w", ErrBadInput)
fmt.Println(err == ErrBadInput) // false
fmt.Println(errors.Is(err, ErrBadInput)) // true
}
err == ErrBadInput is false, because err is now a “wrapper”. But errors.Is(err, ErrBadInput) is true, because errors.Is knows how to look inside.
Important consequence: errors.Is only works if the cause is actually “inside”
If you added the error as text via "%v", then there will be no cause chain, and errors.Is has nothing to search through.
That’s one reason why "%w" is not “just aesthetics”, but a practical tool: it makes the cause programmatically checkable.
3. errors.As: extracting an error of the needed type
If errors.Is answers “yes/no: is it that cause?”, then errors.As answers a different question: “Is there an error of a specific type inside this error (or inside any error in its chain), and can I retrieve it as a value to inspect details?”
You can think of errors.As as a “smart search through the cause chain”: it walks the wrapping chain and tries to find an error of the required type. If it finds one, it writes it into your variable, and then you work with it like a normal object: read fields, make decisions, build a clear message for the user. We don’t need to analyze err.Error() and guess meaning from text—we extract the typed error directly.
Why this is useful in practice
A typed error is especially useful when you need details, not just a category.
For example, strconv.Atoi returns an error of type *strconv.NumError. It has fields, and sometimes it matters what exactly failed to parse or which operation failed.
Let’s make an example: parse a number, wrap the error with context using "%w", and then extract the typed error:
package main
import (
"errors"
"fmt"
"strconv"
)
func main() {
_, err := strconv.Atoi("12x")
err = fmt.Errorf("parse id: %w", err)
var numErr *strconv.NumError
if errors.As(err, &numErr) {
fmt.Println("bad number:", numErr.Num) // bad number: 12x
}
}
We don’t analyze the error string, don’t look for “invalid syntax”, don’t guess by phases of the moon. We check the type of the cause and extract it as a normal Go object.
4. Why errors.As takes &target
Beginners often have an honest question: “Why can’t I just do errors.As(err, target)? Why the ampersand, and why does it sometimes become a ‘pointer to a pointer’?”
errors.As must write the found error into your variable target. For a function to modify a variable, you have to give it the address of that variable. It’s the same logic as fmt.Scan(&x): you pass “where to put the result”.
A short analogy with Scan
You’ve already encountered Scan:
package main
import "fmt"
func main() {
var x int
fmt.Scan(&x) // &x — where to store the read number
}
errors.As works the same way, except it doesn’t “read” from stdin—it “reads” from the error chain.
What *T means in the target type
In examples you often see this:
var numErr *strconv.NumError
errors.As(err, &numErr)
It’s important to read this calmly:
- numErr is a variable that can hold a value of type “pointer to strconv.NumError”.
- &numErr is the address of the variable numErr, so that errors.As can store the found error there.
- Yes, visually it looks like a “pointer to a pointer”, and that’s fine: an “error of that type” is often itself a pointer.
If you keep the phrase “&target is ‘where to write the answer’” in mind, everything becomes noticeably simpler.
5. errors.Is vs errors.As: when to choose which
It’s easy to overuse errors.As: “oh, I can extract the type—so I’ll always extract it”. But in most cases you don’t need the details of a specific error type. You need to understand the meaning of the situation: “invalid input”, “not found”, “no permission”, “I/O problem”. For such branches, errors.Is is a better fit: it reads more simply and ties your code less to the internals of a particular error.
You should use errors.As when you truly can’t do without the “internals”: you need to extract fields of a typed error and use them to shape behavior or a message. For example, to understand which exact value failed to parse, which parameter was bad, or which type was expected.
Below is a table for quick navigation:
| Question | Tool | What you get |
|---|---|---|
| “Is this an error of this specific cause?” | |
|
| “Is there an error of this type inside, and can I have it?” | |
|
In very short: errors.Is answers “is that cause (the sentinel) somewhere inside?”, and errors.As answers “is there an error of the needed type inside so I can extract its details?”
6. Example: parse id and branch handling
Let’s build a small example. Suppose we have a learning console app (let’s call it tasker) that takes a command and arguments. We’re not building a full CLI using flag yet (that’s a different topic), but we already want to:
- parse id from a string,
- return an error with context,
- in main, understand what kind of error it is: “the user entered not a number” or “something else”.
Function parseID: return context without losing the cause
package main
import (
"fmt"
"strconv"
)
func parseID(s string) (int, error) {
id, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("parse id %q: %w", s, err)
}
return id, nil
}
Style matters here: the context is short and to the point. We don’t write “ERROR!!!! parse id failed!!!”, we write the operation: "parse id \"...\"". That’s already useful for a human, and for code the cause err remains available via the chain.
In main: extract *strconv.NumError via errors.As
package main
import (
"errors"
"fmt"
"strconv"
)
func main() {
_, err := parseID("12x")
if err != nil {
var numErr *strconv.NumError
if errors.As(err, &numErr) {
fmt.Println("id must be integer") // id must be integer
return
}
fmt.Println("unexpected error:", err)
}
}
The user-facing message is simple (“id must be integer”), while the “scary” error with details remains in err—you can log it, print it during debugging, or show it in verbose mode.
Alternative: errors.Is for an error “class” via a sentinel
Sometimes we don’t want to depend on a specific standard-library error type. We want our own concept: “invalid input”. Then a sentinel plus errors.Is is more useful: errors.Is checks “there is this marker in the chain”.
How to read other people’s code: manual Unwrap vs errors.Is/errors.As
When you read real Go code (especially the standard library), you’ll see both approaches. Sometimes people manually walk the chain with errors.Unwrap, but more often they use errors.Is and errors.As, because it’s shorter and less error-prone.
To understand the mechanics, it’s useful to imagine the chain like nesting dolls:
flowchart TD
E0["err: 'cmd: parse id '12x': ...'"] --> E1["unwrap -> *strconv.NumError"]
errors.Is/errors.As do roughly the same thing, just without you having to write the loop yourself.
7. Common mistakes when working with errors.Is and errors.As
Mistake #1: checking errors via err.Error() or strings.Contains.
Such code may feel fast at first, but it breaks with any change to the text, language, or dependency version. If you need checkability, use errors.Is (for a specific sentinel cause) or errors.As (for a specific error type).
Mistake #2: after wrapping, comparing err == target and being surprised it “doesn’t match”.
After fmt.Errorf("ctx: %w", ErrX), what you return outward is no longer ErrX, but a wrapper. So comparison with == stops being a usable tool. The correct idea is: “If there is wrapping, then you check the cause via errors.Is.”
Mistake #3: forgetting that errors.As takes the address of a variable (&target).
If you pass not &target but just target, errors.As can’t write the found error, and the code either won’t compile or will be wrong semantically. Keep the analogy in mind: it’s like Scan(&x)—“where to put the result”.
Mistake #4: expecting errors.Is/errors.As to work if you “wrapped” an error using "%v".
fmt.Errorf("ctx: %v", err) adds text, but does not create an unwrap-able cause chain. To errors.Is/errors.As it looks like a plain new error with no root cause inside. If the cause must be checkable, wrap using "%w".
Mistake #5: using errors.As where errors.Is is enough.
errors.As is needed when details of a specific type truly matter. If you’re simply branching behavior (“not found” / “no permission” / “invalid input”), a typed error is often unnecessary coupling to internals. In such cases, it’s simpler and more stable to design recognizable causes and check them with errors.Is.
GO TO FULL VERSION