如果你看到日志里明明有“记录不存在”,却发现 errors.Is(err, ErrNotFound) 返回了 false,最常见的原因不是 errors.Is 失效,而是包装错误时用了 %v。fmt.Errorf 中,%v 只把原错误格式化进新消息;%w 才会把原错误保留在可遍历的错误链里。
官方资料:https://pkg.go.dev/errors
需要让上层继续判断底层错误时使用%w;只需要把错误读给人看、并不打算让调用方识别其类型时,才考虑%v。
为什么 %v 会让 errors.Is 找不到原始错误
先定义一个业务层要识别的哨兵错误。下面两个函数看起来只差一个格式化动词,但它们返回的结构不同。
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("record not found")
func loadWithMessage() error {
// %v 只把底层错误转成文字,新错误不会保留可展开的链路。
return fmt.Errorf("load user: %v", ErrNotFound)
}
func loadWithChain() error {
// %w 在增加上下文的同时保留 ErrNotFound,供 errors.Is 继续判断。
return fmt.Errorf("load user: %w", ErrNotFound)
}
func main() {
fmt.Println(errors.Is(loadWithMessage(), ErrNotFound)) // false
fmt.Println(errors.Is(loadWithChain(), ErrNotFound)) // true
}
两次输出的文字都可能包含 record not found,但文字相同不代表错误对象相同。%v 生成的是新的消息错误,原来的 ErrNotFound 已经不再是它的子错误;%w 则相当于在外层消息与原始错误之间建立了关系。
用 errors.Is 沿错误链判断固定错误
errors.Is 适合判断“这次失败是否属于某个已知错误”。它会先检查当前错误,再沿着错误的 Unwrap 关系继续向内查找。因此,业务函数可以增加上下文,而调用方仍然能够做稳定分支。
func findUser(id int64) error {
if id <= 0 {
// 参数错误直接返回,调用方可以按哨兵错误处理。
return ErrNotFound
}
// 这里模拟存储层没有找到记录。
return fmt.Errorf("find user %d: %w", id, ErrNotFound)
}
func handle(id int64) error {
err := findUser(id)
if err == nil {
return nil
}
// 不比较错误字符串,避免消息改动导致分支失效。
if errors.Is(err, ErrNotFound) {
return fmt.Errorf("user %d is unavailable: %w", id, err)
}
return err
}
这里即使 handle 再包一层,仍然可以通过 errors.Is 找到最底层的 ErrNotFound。如果把其中任意一个 %w 改成 %v,链会在那一层断开,后续调用方就只能看到一段文本。
排查 errors.Is 返回 false 的三个位置
遇到错误链匹配失败时,可以从返回值向下回看,而不是先怀疑目标错误的文字是否相同。
- 检查每一层包装动词。搜索错误传播路径中的
fmt.Errorf,凡是还需要被上层识别的底层错误,都应使用%w并把 error 作为对应参数传入。 - 检查是否比较了另一个实例。两个分别通过
errors.New("record not found")创建的错误,文字相同但不是同一个哨兵;应在包级别复用同一个错误变量,或使用自定义类型配合errors.As。 - 检查包装函数是否真的返回 error。有些日志辅助函数把错误转成字符串后再返回,调用方拿到的已不是原来的错误值,后续自然无法展开。
func wrap(err error) error {
if err == nil {
// nil 不应被包装成一个看似失败的新错误。
return nil
}
// 这里保留原错误,让上层仍可使用 errors.Is 或 errors.As。
return fmt.Errorf("repository operation: %w", err)
}
func inspect(err error) {
for current := err; current != nil; current = errors.Unwrap(current) {
// 逐层打印类型,定位到底是哪一次包装切断了链路。
fmt.Printf("%T: %v\n", current, current)
}
}
errors.Unwrap 只能向下取一层,适合定位链路;实际业务判断通常直接使用 errors.Is 或 errors.As,不必手写循环。
自定义错误也要提供 Unwrap
如果项目使用自己的包装类型,就不能只保存一个 Err error 字段,还要实现约定的方法。否则字段虽然存在,标准库的错误链函数并不知道应该怎样继续。
type QueryError struct {
Query string
Err error
}
func (e *QueryError) Error() string {
return fmt.Sprintf("query %q: %v", e.Query, e.Err)
}
func (e *QueryError) Unwrap() error {
// 返回内部错误,errors.Is 和 errors.As 才能继续向下检查。
return e.Err
}
func query() error {
// 自定义类型增加查询上下文,同时保留底层原因。
return &QueryError{Query: "select user", Err: ErrNotFound}
}
自定义类型实现了 Unwrap() error 后,errors.Is(query(), ErrNotFound) 就能匹配到底层哨兵。若要提取某种具体错误类型,可以用 errors.As,它和 errors.Is 一样依赖错误链完整存在。
什么时候可以使用 %v
%v 并不是错误动词,它仍然适合日志、一次性展示或明确不希望暴露底层错误关系的边界。例如记录一条不会再被上层判断的诊断文本时,使用 %v 可以只关注可读消息。
但在这些位置通常应该使用 %w:
- repository、HTTP client、文件访问等底层函数向上返回错误;
- 上层需要区分不存在、超时、权限不足等已知错误;
- 需要用
errors.As取得*os.PathError等具体类型; - 添加上下文后,仍希望保留原错误用于重试、告警或响应映射。
可以记成一句话:面向人读的文本用格式化,面向程序判断的错误用包装。当你不确定调用方是否需要识别底层错误时,保留链路通常更稳妥;真正对外隐藏实现细节时,再把边界设计成只返回稳定的业务错误。
相关问题
errors.Is 能比较错误字符串吗?
不能。它比较错误值、错误链中的匹配关系以及错误类型提供的匹配逻辑,不应把字符串相等当成错误身份。
fmt.Errorf 可以只写 %w 不加上下文吗?
技术上可以,但通常应增加简短上下文,让日志更容易定位来源。上下文不要替代底层错误,底层错误仍通过 %w 保留。
errors.Unwrap 为什么返回 nil?
通常表示当前错误没有实现可用的 Unwrap 方法,或者链已经走到了最底层。先检查上一层是否误用了 %v。

Go 返回 typed nil interface 为何不等于 nil