错误就是值
可能失败的 Go 函数会把 error 作为最后一个返回值。调用方立即检查它。
输出:
parsed: 42
could not parse: strconv.Atoi: parsing "forty-two": invalid syntax
这就是整个机制。没有异常,没有 try 或 catch,也没有隐藏的控制流:错误只会沿着你的代码传递它的路径走。代价是 if err != nil 要一遍遍地写。好处是每个可能失败的地方都清清楚楚摆在代码里,由你在每一处决定接下来怎么办。
error 类型
error 是一个内置接口,只有一个方法:
type error interface {
Error() string
}
任何带有 Error() string 方法的类型都是错误。nil 错误表示成功。用 fmt.Println(err) 或 %v 打印错误时会调用 Error()。
创建错误
两个函数就能应付大多数情况。
errors.New 创建一个固定文本的错误。fmt.Errorf 用和 Printf 相同的格式化动词生成错误。按照惯例,错误字符串以小写字母开头,结尾不加标点,因为它们通常会被嵌进更长的消息里:load config: open app.yaml: no such file or directory。
if err != nil 模式
地道的写法是:调用、检查、提前返回。成功路径始终贴着左边距,每个失败一发生就立即退出。
func loadUser(id int) (*User, error) {
row, err := db.Query(id)
if err != nil {
return nil, err
}
u, err := parseUser(row)
if err != nil {
return nil, err
}
if err := u.Validate(); err != nil {
return nil, err
}
return u, nil
}
有两个惯例值得注意:
- 出错时,其他返回值返回零值(
nil、0、"")。当err != nil时,调用方不能使用它们。 - 当函数只返回一个错误时,
if err := f(); err != nil会把err的作用域限制在if内,让外层作用域保持干净。
错误返回之后不要再写 else。if err != nil { return err } else { ... } 只会让正常路径平白多缩进一层。
返回错误时添加上下文
原封不动往上传的错误,会丢掉它来自哪里的来龙去脉。open config.yaml: no such file or directory 并不能告诉你启动的哪一步失败了。用 fmt.Errorf 和 %w 动词加上上下文:
输出:
start server: read config: open /etc/myapp/config.yaml: no such file or directory
true
每一层都补上自己当时在做什么,最终的消息读起来就像一条从调用顶层一路通到原因的线索。好的上下文会写明操作和输入:parse line 12、fetch user 42。不要在每一层都加上“error”或“failed”,这条消息本身就已经是错误了。
%w 会包装:它把原错误保存在新错误内部,所以 errors.Is 和 errors.As 仍然能找到它。%v 只复制文本。如果你有意向调用方隐藏实现细节,比如不想让他们依赖某个数据库驱动的错误类型,就用 %v。
检查特定错误:errors.Is 和 errors.As
有时调用方需要针对某一种失败做出反应:文件不存在就“使用默认值”,超时就“重试”。有两个函数可以解决这个问题,而且它们都会穿过每一层包装去查找。
经验法则:
- 和预定义的错误值(
io.EOF、os.ErrNotExist、sql.ErrNoRows这类哨兵错误)比较时用errors.Is,不要用==。错误一旦被包装,==就失效了。 - 出于同样的原因,提取类型化错误时用
errors.As,不要用类型断言。errors.As接收一个指向目标类型变量的指针。 - 永远不要匹配
err.Error()的文本。消息会随版本变化,一旦变了,文本匹配就会悄无声息地失效。
如何定义自己的哨兵错误和错误类型,以及如何用 errors.Join 合并多个错误,在自定义错误一节中介绍。
一个错误只处理一次
一个错误应该恰好被处理一次。处理指的是以下其中一种:返回它(通常要包装)、记录日志后继续、重试,或者把它转换成给用户的响应。同时做两件事,是 Go 代码里最常见的错误处理 bug。
// Wrong: logged here, and returned, so it is logged again by every caller.
if err != nil {
log.Printf("could not fetch user: %v", err)
return err
}
// Right: add context and return. The top of the program logs once.
if err != nil {
return fmt.Errorf("fetch user %d: %w", id, err)
}
既记录日志又返回,会让同一个失败在日志里出现好几次,而且每次的上下文都比最终消息少。让错误一路向上传到能决定怎么处理它的地方(HTTP 处理函数、main、worker 循环),在那里记录日志。
错误的最终归宿
在程序的最顶层,总得有代码对错误采取行动。在 main 里,这通常意味着打印错误并以非零状态码退出:
不带参数运行它,会向 stderr 打印 error: usage: app <name>,并以状态码 1 退出(在“参数”面板里输入一个名字,就能看到另一条路径)。让 main 保持这种结构、把真正的工作放进 run,程序就便于测试,而且 run 里的 defer 语句仍会执行,因为 os.Exit 会跳过延迟调用。
在 HTTP 服务器中,最顶层就是处理函数:它把错误映射成状态码和一条可以安全展示给客户端的消息,并为你记录详细的消息。
可以忽略的错误,以及绝不能忽略的错误
忽略错误有时是对的,但要用 _ 明确写出来,让读代码的人知道这是有意为之:
_ = conn.SetDeadline(t) // best effort
有些调用在实践中不可能失败(strings.Builder.WriteString、bytes.Buffer.Write)。另一些看起来无害,其实不然:对写入过的文件调用 Close,可能会报告数据根本没写到磁盘上,而 json.Marshal 遇到 channel 和函数会失败。拿不准时,就检查。
errcheck linter(已包含在 golangci-lint 中)会报告未检查的错误。go vet 自身不会标记它们。
错误与 panic
Go 也有 panic,但它不是异常机制。正常运行中可能出问题的一切都用错误处理:错误的输入、缺失的文件、网络故障。panic 留给 bug(不可能出现的状态、被破坏的不变量),以及继续运行毫无意义的启动失败。一个库几乎永远不应该让 panic 穿过它的 API。参见 panic 与 recover。
减少重复
if err != nil 写起来很啰嗦,为它增加新语法的提案也屡次被否决;Go 团队在 2025 年宣布,不再推进错误处理方面的语法改动。在语言现有范围内,有一些模式可以减少噪音:
- 提前返回,保持函数短小。 大多数重复来自一个函数里做了太多步骤。
- 粘滞错误。 对一连串写操作,把第一个错误保存在结构体字段中,一旦设置了它,后续调用就什么都不做。
bufio.Writer就是这样工作的:你只需在Flush之后检查一次错误。 - 每个函数只包装一次。 一个捕获了命名返回值的延迟闭包,可以给函数返回的每个错误加上相同的上下文(参见 defer)。
常见错误
- err 不为 nil 时仍然使用返回值。 先检查,再使用。
- 既记录日志又返回。 二选一。
- 包装后还用
==比较。 用errors.Is。 - 用
%v丢掉了原因。 除非你就是要隐藏它,否则用%w。 - 把类型化的 nil 指针当作
error返回。var e *MyErr; return e在调用方看来不是 nil。直接返回字面量nil。 - 消息首字母大写或带标点。
errors.New("Failed to connect.")一旦被包装,读起来就很别扭。写成connect to db: ...。
常见问题
Go 的错误处理是怎么工作的?
可能失败的函数会把 error 作为最后一个返回值。调用方立即检查它:v, err := f(); if err != nil { return err }。error 是一个普通的接口值,只有一个方法 Error() string,nil 表示成功。Go 没有异常。
Go 有 try/catch 吗?
没有。Go 没有异常,也没有 try/catch。可预见的失败以 error 值的形式返回,并用 if err != nil 检查。panic 和 recover 确实存在,但它们用于程序 bug 和无法恢复的状态,而不是正常的错误流程。
在 Go 中怎样返回错误?
把 error 声明为最后一个返回值,成功时返回 nil。固定文本的错误用 errors.New("message") 创建,要给收到的错误加上下文则用 fmt.Errorf("reading %s: %w", name, err)。失败时,其他返回值返回零值。
fmt.Errorf 中的 %w 和 %v 有什么区别?
两者都会把原错误的消息放进新错误里。%w 还会包装原错误,所以 errors.Is 和 errors.As 仍然能找到它。%v 生成的新错误只包含文本。调用方可能需要检查原因时用 %w,想隐藏原因时用 %v。
在 Go 中怎样判断返回的是哪种错误?
用 errors.Is(err, target) 和 io.EOF、os.ErrNotExist 这类哨兵错误比较,用 errors.As(err, &target) 提取 *fs.PathError 这样的特定错误类型。两者都会逐层查看被包装的错误。避免比较 err.Error() 字符串。