Go 的错误处理一直以“显式”著称,但在项目规模上来之后,真正麻烦的往往不是 if err != nil 本身,而是错误被多层包装后还能不能准确识别。Go 1.13 把 Unwrap()、%w、errors.Is 和 errors.As 串成了一套完整方案:既保留上下文,又不丢掉原始错误。
这篇文章就按实际排查问题的顺序来拆解:先看旧写法为什么容易失效,再看错误链是怎么建立的,最后分别说明 errors.Is 与 errors.As 该在什么场景下用。读完你可以更清楚地判断,什么时候应该比较“是不是这个错误”,什么时候应该拿到“这个错误的具体类型和附加信息”。
传统错误处理为什么不够用
在 Go 的早期版本里,错误处理方式很直接:用 error 接口表示错误,用 errors.New() 创建基础错误,用 fmt.Errorf() 补充提示信息。

// 传统的错误创建
err := errors.New("something went wrong")
err = fmt.Errorf("failed to process: %s", msg)这套方式在简单程序里足够,但一旦业务链路拉长,问题就出现了。比如一次请求可能会经过 HTTP 请求、业务逻辑、数据访问、数据库操作几层调用,当数据库抛出错误时,上层通常只知道“失败了”,却很难继续判断这到底是连接超时,还是数据不存在。
很多旧代码会退回到字符串匹配:
if strings.Contains(err.Error(), "timeout") {
// 处理超时
}这种做法最大的麻烦有两个。第一,它非常脆弱,错误文案只要改一点,分支判断就可能失效;第二,它绕开了类型系统,让本来应该靠结构和语义判断的错误处理,变成了单纯的文本匹配。代码一多,这类判断很快就难维护。
Unwrap 和 %w 怎样建立错误链
Go 1.13 对 error 接口做了关键扩展,引入了 Unwrap() 方法:
type error interface {
Error() string
Unwrap() error // Go 1.13 新增
}有了这个方法,一个错误就可以把自己内部包装的原始错误继续暴露出来。这样一来,错误不再只是单独的一条消息,而是形成了可以逐层追溯的“错误链”。
与之配套的,就是 fmt.Errorf() 里的 %w:
// 错误包装
err := database.Query("SELECT...")
return fmt.Errorf("query failed: %w", err)这里的重点不只是拼接出了一句更完整的提示,而是 %w 会把原始错误包装进去,让返回的新错误具备 Unwrap() 能力。这样你在上层既能看到 query failed 这样的上下文,也不会丢掉底层真正的错误对象。
这一步很重要,因为后面的 errors.Is 和 errors.As 都依赖这条错误链工作。没有链,调用方看到的就只是最终那层消息;有了链,调用方才能顺着包装关系继续往下判断和提取。
errors.Is 适合判断“是不是这个错误”
errors.Is 用来在错误链里查找某个目标错误,它的签名很简单:
func errors.Is(err, target error) bool它会沿着整个错误链向下遍历,只要链上的某一层与 target 相等,就会返回 true。这正好解决了“错误被包装后还能不能识别”的问题。
// 定义一个 sentinel error(哨兵错误)
var ErrNotFound = errors.New("resource not found")
func findUser(id int) error {
// 底层返回 ErrNotFound
err := db.Find(id)
if err != nil {
return fmt.Errorf("find user failed: %w", err)
}
return nil
}
func main() {
err := findUser(123)
// 即使错误被层层包装,依然能正确识别
if errors.Is(err, ErrNotFound) {
fmt.Println("用户不存在")
}
}这类场景在业务代码里非常常见:你并不关心底层错误对象里还有多少字段,只关心它是不是某个约定好的错误值,比如 ErrNotFound、权限不足、超时、重复写入等。此时用 errors.Is 最合适,因为它表达的就是“语义上的同一个错误”。
另外,errors.Is 还支持自定义匹配逻辑。如果一个错误类型实现了自己的 Is() 方法,就可以自己定义“什么叫相等”。
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Is(target error) bool {
if v, ok := target.(*ValidationError); ok {
return e.Field == v.Field
}
return false
}上面这个例子里,哪怕 Message 不同,只要 Field 相同,就能被认为是同一类验证错误。换句话说,errors.Is 不只是做字面相等比较,它允许你把业务语义编码进错误匹配规则里。
errors.As 适合提取具体错误类型
如果说 errors.Is 解决的是“要不要进入这个分支”,那 errors.As 解决的就是“进入之后我还想拿到这个错误的详细信息”。它的签名如下:
func errors.As(err error, target interface{}) bool它会在错误链中查找可以赋值给目标类型的错误,并把具体实例写入 target。这意味着你不仅知道“它是某种错误”,还可以进一步读取错误对象上的字段。
// 定义带额外信息的错误类型
type ParseError struct {
File string
Line int
Col int
}
func (e *ParseError) Error() string {
return fmt.Sprintf("%s:%d:%d: parse error", e.File, e.Line, e.Col)
}
func parseConfig() error {
err := parseFile("config.json")
if err != nil {
return fmt.Errorf("parse config failed: %w", err)
}
return nil
}
func main() {
err := parseConfig()
var parseErr *ParseError
if errors.As(err, &parseErr) {
fmt.Printf("解析错误位于 %s 的第 %d 行n",
parseErr.File, parseErr.Line)
}
}这种写法更适合结构化错误。比如解析配置文件时,你不仅想知道“解析失败了”,还希望知道具体是哪个文件、第几行、第几列出错;又比如网络、序列化、权限校验等场景,错误对象里可能会带有更多字段,供上层决定日志内容、重试策略或用户提示。
和 errors.Is 一样,errors.As 也支持自定义行为。如果错误类型实现了 As() 方法,就能自行控制如何向目标类型映射。多数日常业务代码用不到这一步,但它说明 Go 1.13 这套机制并不只是“能用”,而是给复杂错误体系留出了扩展空间。
实际使用时怎么选
把两者放在一起看,区分其实很清楚。

errors.Is:判断某个错误链里是否存在指定错误值,适合哨兵错误、语义判断和分支处理。errors.As:提取某种具体错误类型,适合需要读取字段、拿到上下文信息、做细粒度处理的场景。
这套设计的价值,不只是 API 看起来更统一,而是它让“补充上下文”和“保留原始错误”不再冲突。以前你往往只能二选一:要么返回底层错误,信息太少;要么重新包装成一句新文案,结果上层再也认不出原始错误。现在通过 %w 建立错误链,再配合 errors.Is 和 errors.As,这两个目标可以同时实现。
从 errors.New、fmt.Errorf 到 Go 1.13 的这套错误链机制,Go 的错误处理并没有变复杂,反而是把原本散乱、脆弱的实践收束成了更稳定的范式。以后再遇到层层嵌套的错误,先别急着对着 err.Error() 做字符串匹配,先看它是不是应该被包装成链,再决定用 errors.Is 还是 errors.As 去处理。









