本文永久链接 – https://tonybai.com/2026/09/05/how-to-handle-errors-in-go
大家好,我是Tony Bai。
【导读】
不同于 Java、Python 等语言将异常抛出至主流程之外的 try-catch 体系,Go 语言自诞生起就将“错误视为普通的值(Errors are values)”。这一看似简单的设计,让数以万计的并发与分布式服务拥有了确定性的控制流,却也常被部分开发者误解为“代码里全是重复的 if err != nil”。如何规范、结构化且优雅地治理错误?JetBrains 官方出品的这份深度指南,从基础的 error 接口实现,一路讲到现代 Go(涵盖 Go 1.20 的 Join、WithCancelCause 以及 Go 1.26 泛型新特性 AsType)的高阶工具集与工程陷阱,为每一位追求高可用架构的工程师提供了一份全景式的实战手册。
【文章要点(TL;DR)】
- 底层本质:Go 视错误为普通返回值而非隐式异常,
error接口仅包含Error() string单一方法,极大简化了自定义扩展; - 结构化包装与链条保留:推荐使用
fmt.Errorf配合%w保留底层错误链,坚决杜绝直接字符串拼接导致结构化类型丢失的坏味道; - 类型断言的现代演进:除了经典的
errors.Is与errors.As,新版本推荐优先采用类型安全、免反射且能在编译期拦截错误的泛型方法errors.AsType[E]; - 高阶并发协同:借助 Go 1.20 引入的
errors.Join聚合多个异步错误,利用context.WithCancelCause精准追溯 Goroutine 的级联取消根因; - 严格划定 Panic 边界:
panic/recover绝非业务错误兜底方案,仅限不可挽回的硬件级灾难或硬编码失效,业务失败应沿调用链显式回传; - 工程避坑法则:坚决不向空白标识符(
_)丢弃错误、类库内部严禁私自打日志、I/O 场景善用已处理字节数恢复、警惕log.Fatal直接跳过defer清理。

在主流后端开发语言中,Go 语言的错误处理机制始终是开发者们讨论最热烈的话题之一。不同于 Java、C++ 等语言广泛采用的 try-catch 异常体系,Go 坚持将“错误视为值(Errors are values)”,并将其纳入正常的控制流。这种设计带来了极高的代码透明度与掌控力,但在日常工程实践中,如何优雅地传递上下文、如何正确使用包装与解包、何时该 panic 何时该 recover,常常困扰着许多开发者。
本文译自 JetBrains 官方博客的一篇深度技术指南《How to Handle Errors in Go》。文章系统梳理了 Go 语言的核心错误处理技巧(包括 Go 1.20 的 Join、WithCancelCause 以及前沿的泛型类型断言新特性),并给出了详尽的最佳实践与避坑指南。无论你是刚入门 Go 的新手,还是希望进一步规范工程代码的老手,相信这篇指南都会为你带来系统性的启发。
以下是文章正文。
本文最初由社区贡献者 Christoph Berger 发布于 JetBrains 的 Go Guide 中,后迁移至 JetBrains Go 博客。我们在 2026 年 8 月对其进行了更新,以反映 Go 语言的最新变化。
错误处理是 Go 语言区别于 Java、C++、JavaScript 和 Python 等其他流行语言的核心特性之一。在 Go 中,错误就是普通的值(Errors are values)。其他语言倾向于将错误处理剥离在主代码执行流之外,而 Go 则将错误视为程序正常控制流的组成部分。如果一个函数遇到了错误,它会将该错误连同其他返回值一起返回。调用方有责任检查该错误并做出相应处理。
一个典型的 Go 包或应用程序在运行时可能会遇到各种类型的错误,包括逻辑错误、I/O 错误、网络错误、数据校验错误等。每种类型的错误都可能需要特定的处理方式。Go 提供了一套工具和技术来应对不同类型的错误。
本文将深入探讨 Go 错误处理的方方面面。你将学习到错误处理的实用技术与最佳实践、如何针对特定类型的错误进行处理,以及如何避免错误处理中的常见陷阱。
准备工作
本指南中使用的所有示例代码均已在文中内嵌展示,因此仅阅读代码片段就足以理解核心思想。不过,如果你想亲自上手运行并调试代码,我们在 GoLand 博客的相关代码仓库中提供了配套示例。本指南对应的代码位于 error-handling 目录下。
你可以自由选用喜欢的 IDE,也可以安装 GoLand IDE。GoLand 提供免费试用;如果你刚接触 GoLand,这正是一个绝佳的体验机会!
随后,请 Fork 或克隆包含本指南代码的仓库。
按照以下步骤在 GoLand 中打开代码:
- 启动 GoLand。
- 如果是全新安装,会弹出欢迎界面。点击 Open(打开)按钮。
- 在弹出的文件选择对话框中,导航到你刚才克隆的代码仓库,选中
error-handling目录,然后点击 Open。
大功告成!在阅读本指南的过程中,请保持 IDE 随时可用。
Go 中主流的错误处理技术
正如前面所提到的,Go 中所有的错误处理都基于“错误即值”这一理念。在 Go 中,错误与其他任何值没有区别。错误值属于内置的 error 类型。但这个类型到底是什么?幸运的是,GoLand 让我们可以极其方便地查看 Go 本身的源码。
在 Project(项目)窗格中,向下滚动到 External Libraries(外部库)部分,展开 Go SDK。

如果你无法展开 builtin.go,请点击 Project 窗格右上角的三点菜单,选择 Tree Appearance(树形外观),并确保勾选了 Show Members(显示成员):

继续向下滚动,直到在 builtin.go 下方看到 error 类型,点击它。文件 builtin.go 会在编辑器区域打开并展示该错误类型:
type error interface {
Error() string
}
error 类型是一个仅包含单个方法 Error() string 的接口。在此处使用接口类型,使你可以非常轻松地通过让自定义类型实现该 error 接口来创建自定义错误类型。
接下来,让我们看看具体该如何处理错误。
返回错误
在大多数情况下,当一个函数遇到错误时,它自身并不具备妥善处理该错误所需的完整上下文,因此它必须将错误返回给上层调用者。
例如,看一下示例代码(readfile.go)中的 func ReadFile() 函数:
func ReadFile(path string) ([]byte, error) {
if path == "" {
// 使用 errors.New() 创建一个错误
return nil, errors.New("path is empty")
}
f, err := os.Open(path)
if err != nil {
// 包装错误。
// 如果格式化字符串使用 %w 格式化错误,
// fmt.Errorf() 会返回一个实现了 "func Unwrap() error" 方法的错误。
return nil, fmt.Errorf("open failed: %w", err)
}
defer f.Close()
buf, err := io.ReadAll(f)
if err != nil {
return nil, fmt.Errorf("read failed: %w", err)
}
return buf, nil
}
ReadFile() 会检查传入的路径;如果路径为空,它会创建一个新错误并将其返回。由于此时本该由 ReadFile() 返回的数据并不存在,因此该函数返回一个 nil 值:
if path == "" {
return nil, errors.New("path is empty")
}
按照惯例,如果一个函数返回错误值,错误通常永远位于返回值列表的末尾(最右侧):
func ReadFile(path string) ([]byte, error) {
当 ReadFile() 被调用时,它会返回文件内容和一个错误值:成功时错误值为 nil,失败时为非 nil。通常,返回的错误值会被赋值给一个名为 err 的变量(参见配套仓库中的 main.go):
_, err := ReadFile("no/file")
if err != nil {
fmt.Println("Error:", err)
}
由于本指南专门探讨错误处理,这里并不关心 ReadFile() 成功返回的内容,因此该返回值被丢弃到了空白标识符(_)中。
现在,调用者可以检查该错误是否为非 nil,并据此做出相应的处理。
Panic 与 Recover
刚接触 Go 的开发者可能会怀念其他语言中提供的 try...catch 机制。不过,Go 也有一个作用类似的机制:panic 和 recover。但请务必当心!与 try...catch 不同,panic 和 recover 绝不应该成为处理常规错误的标准手段。只有在错误确实出乎意料且根本无法在当前流程中处理时,使用 panic 才是合理的。在这种情况下,最好让应用程序尽早崩溃并重新启动。稍后你会在“最佳实践”部分了解更多相关内容。
一个“绝不应该发生”的错误示例,就是对写死为字面量字符串的正则表达式编译失败。由于正则表达式在编译期就已明确已知,开发者理应确保它语法正确,从而绝不会在运行时编译失败。为了强制推行这一点,regexp 包提供了一个名为 MustCompile() 的函数。前缀 Must 明确表明:如果无法编译给定的正则表达式,该函数就会触发 panic。
为了演示这一点,verifypath.go 文件中包含了一个用于验证指定路径是否合法的函数。然而,开发者把正则表达式写错了——少了一个右括号:
func isValidPath(p string) bool {
pathRe := regexp.MustCompile(`(invalid regular expression`)
return pathRe.MatchString(p)
}
如果在没有任何防范措施的情况下调用该函数,程序会立刻崩溃:
goroutine 1 [running]:
regexp.MustCompile({0x1005ca16d, 0x1b})
/opt/homebrew/opt/go/libexec/src/regexp/regexp.go:319 +0xac
main.isValidPath({0x1005c76af, 0xd})
/Users/you/dev/JetBrains/jetbrains-go-code-samples/awesomeProject/error-handling/verifypath.go:6 +0x54
main.main()
/Users/you/dev/JetBrains/jetbrains-go-code-samples/awesomeProject/error-handling/main.go:21 +0x24
Process finished with the exit code 2
堆栈轨迹(Stack trace)清晰地揭示出 verifypath.go 的第 6 行是触发 panic 的源头。
在某些业务场景下,直接让应用程序崩溃是不可接受的。试想一个必须不间断运行的 HTTP 服务器:如果处理某个请求时发生了 panic,只要条件允许,其他所有请求仍应继续正常响应。为了做到这一点,net/http 包采用了 Go 的 recovery(恢复)机制。
针对会触发 panic 的 isValidPath() 函数,下面介绍了其恢复机制在实践中生效的两个步骤场景:
1. 在调用函数中添加延迟函数调用(deferred function call)
isValidPath() 的调用者会在函数体起始处附近设置一个 defer 延迟函数调用:
defer func() {
// 延迟执行的代码 ...
}() // <- 千万不要漏掉括号,这是一个真正的函数调用!
无论包含该代码的外层函数是通过正常的 return 返回退出,还是由 panic 触发退出,被延迟的函数(deferred functions)都会被自动执行。
2. 在延迟函数中调用 recover()
延迟函数可以判断自己究竟是因为正常返回被调用,还是因为发生了 panic 而被触发。它只需调用 recover() 并检查其返回的错误即可(参见 main.go 中 func main() 的末尾部分):
defer func() {
// 该函数是由 panic 触发调用的吗?
if r := recover(); r != nil {
// 是的:从 panic 中恢复
fmt.Println("Recovering")
// ...
}
}()
如果返回的错误是 nil,说明该延迟函数是在正常返回时被调用的,不需要进行任何恢复操作。
如果延迟函数是由 panic 触发的,recover() 会返回导致 panic 的具体错误。此时,延迟函数就可以执行从 panic 中恢复所需的任何必要补救逻辑。
记录错误日志(Logging errors)
如果一个函数在接收到被调函数的错误后能够对其进行处理,它可能希望将该错误的相关信息写入日志文件中。
在 Go 中记录错误日志非常直观,这得益于标准库中的 log 包,以及从 Go 1.21 开始引入的结构化日志包 slog。
下面是一个在上一节的延迟函数中使用 log 包的示例:
if r := recover(); r != nil {
log.Printf("Recovering from error '%v'\n", r)
}
log.Printf() 是 fmt.Printf() 的直接替代品,它会将日志输出写入标准日志记录器的输出目标中。要格式化错误类型,可以使用格式化动词 %v,它能以默认格式打印出对应的值。
补充说明: 如果你在编写通用类库代码,建议不要在库内部打印任何日志。依赖该库的使用者对于“应该使用哪种日志框架”以及“应该向 stdout 还是 stderr 打印什么内容”有着各自完全不同的见解。因此,最佳做法通常是仅将错误向上层返回,让类库的使用者自行决定如何记录日志。
使用错误包装(Error wrapping)
错误往往会沿着多层函数调用链“向上冒泡(bubbles up)”。换句话说,一个函数收到错误,并通过返回值将其回传给它的调用者;调用者可能会继续如法炮制,直到调用链中的某个上层函数最终处理或记录该错误。在这个“冒泡”过程中涉及的每个函数,都可以在将错误交还给上层之前,为其附加极具价值的上下文信息。以这种能够保留原始错误链条的方式传递错误,就被称为错误包装(Error wrapping)。你在添加上下文的同时,将原始错误保留在新错误内部,后续就可以对新错误进行解包,以检查或匹配底层的原始错误。
函数只有在无法添加任何有价值信息的情况下,才应该原封不动地直接传递错误:
if err != nil {
// 仅在无法添加任何额外上下文时才这样做!
return err
}
在其他所有情况下,都应该附加上恰当的上下文信息。然而,仅仅使用字符串拼接将新错误信息与原始信息拼在一起是行不通的:
// 错误做法!
if err != nil {
return errors.New("open failed:" + err.Error())
}
这种做法虽然保留了原始的文本描述,但却把错误本身“压平”成了一个普通的纯字符串。这样一来,底层的类型和结构化信息将全部丢失,上层调用者再也无法对其进行解包和深入检查。
正确的做法是使用错误包装。可以使用 fmt.Errorf() 以及专用的格式化动词 %w 来将一个错误“包装”进另一个错误中。参见文件 readfile.go 中的 ReadFile() 函数:
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open failed: %w", err)
}
稍后你会看到,os.Open() 返回的错误类型包含许多附加信息。通过对错误进行包装,所有这些附加信息都被完好无损地保留了下来。
解包已包装的错误
一个函数返回的错误内部可能嵌套了一层或多层被包装的错误。直接打印或记录收到的顶层错误,也会一并输出所有被包装错误的错误信息。然而,有时你需要确定某类特定的错误是否正嵌套在层层错误链条的内部。
例如,让我们看看如何在 func main() 中处理 ReadFile() 的错误:
_, err := ReadFile("no/file")
log.Println("err = ", err)
// 解包 os.Open() 返回的底层错误
log.Println("errors.Unwrap(err) = ", errors.Unwrap(err))
该代码片段将打印出:
Reading a single file: err = open failed: open no/file: no such file or directory
Reading a single file: errors.Unwrap(err) = open no/file: no such file or directory
包装后的完整错误信息是 open failed: open no/file: no such file or directory,而解包后的错误仅包含 open no/file: no such file or directory,排除了包装时额外添加的 open failed: 前缀。
依此类推,你可以逐层解包,直到到达错误链的最底层。
检查特定错误类型
在某些情况下,你需要判断已包装的错误链条中是否存在某种特定类型的错误。
例如,os.Open 会返回一个 fs.PathError 类型的错误,该类型不仅记录了错误本身,还记录了导致错误的操作以及具体路径。如果你能检测出错误链中包含该类型错误,就能充分利用这些附加信息来排查故障。
为了实现这一点,errors 包提供了三个函数:Is()、As(),以及在 Go 1.26 中引入的 AsType()。
errors.Is()
函数 func Is(err, target error) bool:如果错误 err 与 target 是同一种错误(或包装了该错误),则返回 true。
在 ReadFile() 函数的示例中,你可以验证返回的错误是否就是(或包装了)fs.ErrNotExist 错误:
_, err := ReadFile("no/file")
log.Println("err is fs.ErrNotExist:", errors.Is(err, fs.ErrNotExist))
输出结果为:
err is fs.ErrNotExist: true
errors.As()
你可能还想访问具体的路径信息。为此,你不仅需要确保该错误包装了 fs.PathError,还需要能访问这个 PathError 实例及其所有方法。
为此,可以使用函数 func As(err error, target any) bool。与 Is() 类似,如果 err 属于 target 所指向的类型(或包装了该类型的错误),As() 就会返回 true,同时它还会解包该错误并将其赋值给 target。
这需要预先定义一个 fs.PathError 类型的指针变量,并将该变量的指针传递给 As():
target := &fs.PathError{}
if errors.As(err, &target) {
log.Printf("err as PathError: path is '%s'\n", target.Path)
log.Printf("err as PathError: op is '%s'\n", target.Op)
}
这将把失败的路径以及具体操作记录到日志中:
err as PathError: path is 'no/file'
err as PathError: op is 'open'
errors.AsType()
Go 1.26 引入了 AsType(),它是 As() 基于泛型实现的、类型安全的替代方案。其函数签名如下:func AsType[E error](err error) (E, bool)。
AsType() 无需预先声明一个目标变量并传递其指针,而是将你要查找的目标错误类型直接作为类型参数传入,并返回两个值:匹配到的错误实例(类型为 E)以及一个表示是否匹配成功的布尔值。这避免了 As() 底层所依赖的反射机制,并消除了因传入非法 target 指针而引发的运行时 panic 风险。
if target, ok := errors.AsType[*fs.PathError](err); ok {
log.Printf("err as PathError: path is '%s'\n", target.Path)
log.Printf("err as PathError: op is '%s'\n", target.Op)
}
与 As() 示例相同,它同样能准确记录失败的路径和操作:
err as PathError: path is 'no/file'
err as PathError: op is 'open'
相比 As(),AsType() 具备多项优势:由于你在调用时直接明确指定了错误类型,编译器能为你进行严格的类型检查,诸如“本应传指针却传了普通值”这类低级错误将在编译期被直接捕获,而不会在运行时因传入不合规的 target 而触发 panic;此外,AsType() 避免了内部反射开销,执行性能也会稍好一些。
As() 并没有被弃用,现有的老代码依然可以正常工作。但在编写新代码时,推荐优先使用 AsType()。特别是当你需要接连检查多种不同类型的错误时,它显得尤为便捷,因为每个匹配到的错误变量作用域都严格限制在各自的 if-else 分支内:
if pathErr, ok := errors.AsType[*fs.PathError](err); ok {
log.Println("path error at:", pathErr.Path)
} else if linkErr, ok := errors.AsType[*os.LinkError](err); ok {
log.Println("link error during:", linkErr.Op)
}
合并多个错误(Joining errors)
通常情况下,错误是在向各自调用者回传的过程中被逐一单向包装的。但有时,一个函数需要收集多个错误并将它们打包合并为一个整体错误。
以 readfiles.go 中的 ReadFiles() 函数(注意是复数)为例。该函数会读取多个文件,并返回所有成功读取的文件内容。如果一个或多个文件读取失败,ReadFiles() 会将所有产生的错误收集起来并合并为一个。
为此,errors 包提供了 Join() 函数(自 Go 1.20 引入)。让我们看看 ReadFiles() 是如何利用 Join() 函数的:
func ReadFiles(paths []string) ([][]byte, error) {
var errs error
var contents [][]byte
if len(paths) == 0 {
// 使用 fmt.Errorf() 创建新错误(但不使用 %w):
return nil, fmt.Errorf("no paths provided: paths slice is %v", paths)
}
for _, path := range paths {
content, err := ReadFile(path)
if err != nil {
errs = errors.Join(errs, fmt.Errorf("reading %s failed: %w", path, err))
continue
}
contents = append(contents, content)
}
return contents, errs
}
如果 for 循环内部发生错误,它并不会中断整个循环。相反,该错误会被追加合并到变量 errs 中,循环继续执行,并在后续出现新错误时继续合并记录。
最终,ReadFiles() 既返回了所有成功读取的内容,也返回了合并后的所有错误信息。
处理合并后的错误
你可能会下意识地认为合并后的错误可以用 errors.Unwrap() 来解包。
遗憾的是,事实并非如此。合并后的错误在底层实际上是一个错误切片 []error。而 Unwrap() 函数的设计初衷是返回单个 error。如果对合并后的错误调用 Unwrap(),它只会返回 nil:
_, err = ReadFiles([]string{"no/file/a", "no/file/b", "no/file/c"})
log.Println("joined errors = ", err)
log.Println("errors.Unwrap(err) = ", errors.Unwrap(err))
第二行日志将输出:
errors.Unwrap(err) = <nil>
幸运的是,我们有办法解开这个合并错误切片。合并错误类型本身提供了一个 Unwrap() []error 方法,用于返回底层的错误切片。
要调用这个 Unwrap() 方法,你只需要通过类型断言确认该错误变量实现了该方法,随后即可安全地调用它:
e, ok := err.(interface{ Unwrap() []error })
if ok {
log.Println("e.Unwrap() = ", e.Unwrap())
}
这将完整打印出合并错误切片中的所有内容:
Reading multiple files: e.Unwrap() = [reading no/file/a failed: open failed: open no/file/a: no such file or directory reading no/file/b failed: open failed: open no/file/b: no such file or directory reading no/file/c failed: open failed: open no/file/c: no such file or directory]
基于 Context 的错误处理
context 包常用于控制请求的超时,或在接收到取消请求时级联取消多个 goroutine。如果你使用的是可取消的 Context,你可以检查并处理导致取消的具体错误。
自 Go 1.20 起,通过使用带有原因的取消上下文 WithCancelCause,你甚至可以在取消 Context 时附带一个自定义的错误信息。基本示例如下:
parent := context.Background()
ctx, cancel := context.WithCancelCause(parent)
defer cancel(nil) // 将原因设置为 Canceled
cancel(fmt.Errorf("%w", myError)) // 将原因设置为 myError
fmt.Println(ctx.Err()) // 输出:context.Canceled
fmt.Println(context.Cause(ctx)) // 输出:myError
(构建并发 goroutine 和取消场景的代码可能会迅速变得复杂。可以在 readfiles_concurrent.go 中查看完整示例。)
Context 函数 WithCancelCause() 返回一个 context 实例和一个接收 error 类型的取消函数 cancel。在调用 cancel 时,可以传入自定义的错误。任何拥有该 Context 访问权限的相关方,都可以通过 context.Cause(ctx) 随时提取出该自定义错误。
Go 错误处理的最佳实践
在掌握了这些错误处理技术之后,我们再来看看日常编写 Go 代码时应遵循的一些最佳实践。
善用 defer 函数
一个函数可能包含多个退出路径——无论是通过普通的 return 语句,还是因为触发了 panic。每当函数分配了需要释放的资源(如打开文件、网络连接或创建 goroutine),都应该使用 defer() 在函数退出时统一清理所有未释放的资源。
ReadFile() 函数中就包含一个用于关闭打开文件的延迟调用:
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open failed: %w", err)
}
defer f.Close()
请注意,defer f.Close() 严格置于错误检查之后。如果 os.Open() 执行失败,它会返回一个 nil 文件句柄和一个非 nil 的错误,此时根本没有需要关闭的资源。如果在检查错误之前就执行 defer,在函数退出时就会引发空指针解引用(nil-pointer dereference)异常。
提供明确具体的错误上下文
没有什么比在日志文件中看到诸如 ERROR: EPIC FAIL 这样毫无上下文线索的晦涩错误信息更令人抓狂的了。
如果你还在纳闷现实中会不会有人这么写:是的,现实世界中充斥着这种日志。这类信息带来的灾难是,即便是本该最熟悉业务代码的开发者,也无法搞清楚到底是什么引发了这条日志:
“看啊,这段代码在太多地方被调用了,单凭日志里这句话,我们根本无法断定到底是哪里出了问题。日志里留下的上下文实在是太少了。”
因此,如果一个函数遇到了错误,绝不应该将其原封不动地直接传回调用链上层。只要有任何有助于排查问题的上下文信息,都应该通过将错误包装为新错误的方式将这些信息附加进去。(参见前文关于错误包装的章节。)
仅在必要时才使用 Panic 与 Recover
初学 Go 的开发者常常抱怨 Go 冗长繁琐的错误检查语法,妄图图省事让函数直接 panic,并在最顶层通过 recover 进行统一兜底捕获。然而,这种做法非常不符合 Go 语言的惯用法(unidiomatic Go),并且存在大量弊端。首当其冲的是,这种做法根本无法附加有价值的上下文信息(如前一节所述)。此外,panic 会脱离常规的调用/返回流程强行展开调用栈,使得顶层函数与引发 panic 的底层函数之间的所有中间函数完全缺失错误处理逻辑。阅读代码的人根本无法预知这些中间函数是否会感知到错误。对比来看,Java 拥有 throws 关键字来显式声明函数可能抛出的所有异常,而 Go 并没有类似语法,开发者无法直接看出某个被调函数内部是否会发生 panic。Go 原生的标准错误处理机制能够使错误的流向清晰透明、一目了然。
Go 之所以将错误视为程序控制流的常规组成部分,是因为事实本就如此。一旦发生错误,就应当被立即处理,或者层层回传给上层调用者,直到调用链中某个具备上下文的函数妥善处理它或将其写入日志以便后续排查。
在检视一个函数时,你应该能一眼看出它可能会遇到哪些错误,以及它是如何将这些错误向上层传递的。
调用 panic 应该严格限定在绝不应该发生的意外严重错误上。正如“Panic 与 recover”一节所见,写死在代码里的硬编码正则表达式就是一个典型例子:既然是硬编码,它就必须被精心设计和充分验证,绝不允许在运行时编译失败。
此外,还有一类属于完全无法挽回的底层错误,例如内存耗尽(OOM)。如果系统根本无法分配所需的内存,应用程序就失去了继续运行的基础条件,此时应当果断触发 panic。
但另一方面,运行时的用户输入是天生不可靠的。任何由于用户输入不合规、文件无效或缺失、网络请求超时等可预见的失败原因导致的异常,都能够并且应当作为常规错误来进行处理。
选用遵循错误处理最佳实践的第三方库
如果你需要在多个功能雷同的第三方库之间做出抉择,请务必选择那个严格遵循错误处理最佳实践的库。
如果一个库虽然 API 设计花哨诱人,但在错误处理上马虎粗糙,你选择它只会自讨苦吃。任何会随意吞掉错误、不原样向外透传,或者错误信息中毫无上下文信息的第三方库,最终都会把故障排查变成一场碰运气的调试噩梦。
因此,在引入第三方库之前,花点时间看一下它的源码,确认其内部实现是否稳健且具备规范的错误处理。这种审慎在长远来看必将带来丰厚的回报。
在适宜的场景下创建自定义错误类型
回顾“检查特定错误类型”一节,其中 os.Open 返回了 fs.PathError。
该错误是一个结构体,它实现了 Error()、Unwrap() 和 Timeout() 方法,并提供了 Path、Op 和 Error 等结构化字段来承载详细的错误信息:
type PathError struct {
Op string
Path string
Err error
}
func (e *PathError) Error() string { return e.Op + " " + e.Path + ": " + e.Err.Error() }
func (e *PathError) Unwrap() error { return e.Err }
// Timeout 报告该错误是否代表超时。
func (e *PathError) Timeout() bool {
t, ok := e.Err.(interface{ Timeout() bool })
return ok && t.Timeout()
}
同理,你也可以按照这种方式创建自己的自定义错误类型。唯一必须强制实现的方法是 Error();但如果你一并实现了 Unwrap() 方法,标准库中的 errors.Unwrap() 函数就能够对你的自定义错误进行无缝解包。
针对特定类型错误的处理
由于某些错误的特殊属性,它们需要特殊的对待。这些错误类型包括网络错误、I/O 错误和系统错误。
网络错误
网络连接失败需要区别对待。网络错误可能是由永久性故障导致的,也可能仅仅是偶发、暂时的波动。负责处理网络错误的代码必须能够准确区分这两种情况。
以发起一个新的 TCP 连接为例。该任务可能会因为网络出现短暂波动而失败,或者因为对端系统正在重启、瞬时过载而暂时无法接受新连接。
在遇到这类情况时,通常希望稍后尝试重新连接。例如,标准库中的 net.Dial() 函数就通过返回一个专用的错误类型 net.OpError 来支持这一需求,该类型提供了一个名为 Temporary() 的方法,用于测试该错误是否属于预计最终会自动恢复的临时性错误。
借助 Temporary() 方法,你可以实现一个简单的重试机制(如下所示),或者采用指数退避(Exponential backoff)等更为精细的重试策略:
func connectToTCPServer() error {
var err error
var conn net.Conn
for retry := 3; retry > 0; retry-- {
conn, err = net.Dial("tcp", "127.0.0.1:12345")
if err == nil {
// 检查 err 是否为 net.OpError
opErr := &net.OpError{}
if errors.As(err, &opErr) {
log.Println("err is net.OpError:", opErr.Error())
// 检查该错误是否为临时性错误
if opErr.Temporary() {
log.Printf("Retrying...\n")
continue
}
retry = 0
}
}
}
if err != nil {
return fmt.Errorf("connect failed: %w", err)
}
defer conn.Close()
// 发送或接收数据
return nil
}
I/O 错误
在读取或写入大量数据后一旦发生 I/O 错误,故障恢复可能会变得非常棘手,尤其是当截至发生错误前已处理完毕的数据可能需要被重新读取或重新写入时。
为了实现更高效的故障恢复,标准库中绝大多数与 I/O 相关的函数和方法在返回错误的同时,还会返回已成功处理的字节数。最典型的范例就是 io.Reader 接口中的 Read() 函数:
type Reader interface {
Read(p []byte) (n int, err error)
}
错误恢复流程可以利用这一返回的字节数信息,精准定位并从被打断的断点处继续执行 I/O 操作。
重要提示: io 包提供了一个哨兵错误值(sentinel error value)io.EOF(定义为 errors.New("EOF")),用于指示输入流已成功(!)读取完毕。任何实现了 io.Reader 接口的类型,都应当严格遵守官方文档中规定的返回语义:
……当 Reader 在输入流末尾读取到非零字节数时,既可以返回
err == EOF,也可以返回err == nil。随后的下一次 Read 调用应当返回0, EOF。
Go 错误处理中应避免的常见陷阱
虽然 Go 的错误处理方式乍看之下可能有些另类,但其实际逻辑清晰且直截了当。然而,这并不意味着在错误处理中不会犯错。以下是应当极力避免的常见错误。
忽略错误
开发者在任何编程语言中会犯的最大错误就是对错误视而不见。没有在早期捕获错误,极易引发连锁的连带反应,导致后续故障比最初的原始错误更难排查。
因此,避免错误处理陷阱的第一法则就是:永远不要将返回的错误值直接赋值给空白标识符(_)。
此外,还要特别警惕那些返回值仅有一个 error 的函数。Go 语言规范并不会强制阻止你完全忽略单个返回值,但你可以利用静态代码分析工具(Linter)来检测出这些被忽略的错误返回值。(GoLand 甚至会在编辑器中直接高亮标出未处理的错误,帮你轻松避开这类疏忽。)
冷知识:你知道 fmt.Println() 实际上也是有错误返回值的吗?
总而言之,千万不要这样做:
WriteString(w, s)
正确做法如下:
n, err := WriteString(w, s)
// 在此处处理错误,详见下文
传递错误时未包装附加的上下文
通常情况下,一个函数在收到调用其他函数返回的错误时,几乎总能为其补充有价值的上下文信息。
因此,每当你发现自己顺手写出这样的代码时:
n, err := WriteString(w, s)
if err != nil {
return err
}
请退一步想一想,看看能否把上下文信息一并附加上去。在绝大多数情况下你都是可以做到的。甚至仅仅带上函数名也是极有价值的信息,因为这能让你清晰还原导致故障发生的函数调用链路:
if err != nil {
return fmt.Errorf("after writing %d characters: %w", n, err)
}
现在多敲这几个按键,日后排查问题时会为你节省海量的宝贵时间。
错误信息过于宽泛笼统
在编写错误信息时,表述应尽量精准具体。尽可能把手头掌握的所有上下文信息全部放进去。
像 “database error” 这种错误信息,背后可能有成吨截然不同的诱因。信息只写一句 “database error” 毫无意义,对定位问题没有任何实质帮助。
尽可能在错误信息中塞入充实的信息。也可以考虑创建能够携带结构化数据的自定义错误类型,参考 os.PathError 即可。
使用了不恰当的错误类型
错误值的具体底层类型看似只是一个微不足道的细节。毕竟所有的错误都实现了 type error interface{ Error() string } 接口,所以归根结底,错误不就是一个个被高级包装过的字符串吗?
完全错误。自定义错误类型可以携带结构化的额外元数据,并支持通过 errors.Is()、errors.As() 以及 errors.AsType() 进行高级深入的错误检查。
因此,每当你要将错误返回给调用者时,请确保所选用的错误类型与当前的错误上下文是精准匹配且恰当的。
遗漏错误日志
错误日志是排查系统故障不可或缺的依据。无论是应用程序有能力妥善处理错误,还是错误严重到迫使应用程序不得不终止运行,程序都应该把该错误记录下来,以供事后复盘分析。
总体原则是:如果一个函数捕获到了错误,它要么就地处理掉该错误,要么将其返回给调用者。
如果函数有能力就地解决,或者出于某种客观原因无法继续向上返回(例如当前处于 main() 函数中),则该函数务必完整记录该错误以及所有的上下文信息。
每一次错误的发生,都意味着一次修复潜在 Bug 或优化改进代码的宝贵机会。千万不要让这个机会无声无息地溜走。
使用 log.Fatal() 记录错误
当应用程序遭遇不可逆转的严重错误时,调用 log.Fatal() 似乎是顺理成章的选择——它不仅能方便地打印日志,还会立即退出进程。
然而,这里隐藏着一个巨大的陷阱:log.Fatal() 底层调用的是 os.Exit()。与 panic() 不同,os.Exit() 完全无法被 recover,并且会直接跳过所有未执行的 defer 延迟函数。
工程上的良好实践是:编写 func main() 时确保其中不依赖任何需要收尾的 defer 函数,并且仅在 main() 函数的最外层使用 log.Fatal() 或 os.Exit()。
未充分考虑错误恢复
在很多场景下,“尽早崩溃(Crash early)”确实是良训。让出现异常的应用程序直接崩溃,能确保它后续可以在一个干净无污染的状态下重新启动。然而,直接崩溃并不总是最优解。
- 如果一个错误很容易通过备用逻辑进行补救,让整个应用程序直接崩溃就属于反应过度。
- 如果系统承诺了极高的可用性(SLA),竭尽全力从错误中恢复、保障服务连续性,往往好过直接重启导致的系统震荡。
- 如果程序衍生了大量的并发 goroutine,通常只需优雅退出那个观察到错误条件的特定 goroutine 即可。
http.ListenAndServe()就是贯彻这种策略的绝佳典范:所有传入的 HTTP 请求都在独立的 goroutine 中处理,即便其中某个 goroutine 发生 panic,ListenAndServe()也会在顶层拦截并恢复该 panic,从而确保其他所有并发运行的请求处理器完全不受波及。
总结: 精心设计的错误恢复机制能够让应用程序获益良多。
结语
Go 语言的错误处理机制涉及的语法构件极少,因此上手掌握非常快。而真正的错误处理艺术,在于懂得如何针对各种具体的错误场景做出最优决策,以及如何妥善治理错误在整个调用链路上的传递过程。
在本指南中,你系统学习了实用的错误处理手段、最佳实践规范、特定错误类型的应对策略,以及日常编码中最需规避的典型误区。掌握这些知识与技能,能够帮助你写出更易于维护、更易于故障排查的健壮代码。但是,你知道如何在 Go 中安全(Securely)地处理错误吗? 欢迎阅读我们的下一篇错误处理深度进阶指南!
原文链接:https://blog.jetbrains.com/go/2026/09/02/how-to-handle-errors-in-go/
还在为写 Agent 框架频频死循环、上下文爆炸而束手无策?我的新专栏 《从0 开始构建 Agent Harness》 将带你:
- 抛弃臃肿框架,回归“驾驭工程 (Harness Engineering)”的第一性原理
- 用 Go 语言手写 ReAct 循环、并发拦截与上下文压缩引擎等,复刻极简OpenClaw
- 构建坚不可摧的 Safety Middleware 与飞书人工审批防线
- 在底层实现 Token 成本审计、链路追踪与自动化跑分评估
- 从“调包侠”进化为掌控大模型边界的“AI 操作系统架构师”
扫描下方二维码,开启从 0 开始构建Agent Harness 的实战之旅。

你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?
- 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
- 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
- 想打造生产级的Go服务,却在工程化实践中屡屡受挫?
继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!
我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。
目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。
