刚开始学 Go 的时候,很多人都会被这段代码刷屏:
if err != nil {
return err
}
写多了以后,心里难免冒出一个问题:
为什么 Go 到处都要手动判断 err?
为什么不像其他语言那样 try/catch?
这篇文章就从新手视角,把 Go 的错误处理机制讲清楚。
你会看到:
-
error到底是什么 -
为什么 Go 推荐显式处理错误
-
nil在错误处理中是什么意思 -
如何创建错误
-
如何给错误添加上下文
-
如何判断一个错误是不是某种错误
-
如何取出自定义错误里的字段
-
panic和普通错误有什么区别 -
实战中怎样写出清晰的错误处理代码
先给一句核心结论:
Go 把错误当作普通值处理。
错误不是隐藏的异常控制流,而是函数返回值的一部分。你看得见它,也必须决定怎么处理它。
一、Go 的 error 是什么
在 Go 里,error 是一个内置接口。
它可以理解成这样:
type error interface {
Error() string
}
只要一个类型实现了:
Error() string
它就可以作为 error 使用。
最简单的例子:
package main
import (
"errors"
"fmt"
)
func divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
func main() {
result, err := divide(10, 0)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("result:", result)
}
输出:
error: division by zero
这里的函数返回两个值:
func divide(a, b int) (int, error)
第一个值是正常结果。
第二个值是错误。
如果没有错误,返回:
return result, nil
如果发生错误,返回:
return zeroValue, err
这里的 zeroValue 是对应类型的零值,例如:
-
int的零值是0 -
string的零值是"" -
bool的零值是false -
指针、slice、map、channel、interface 的零值是
nil
二、为什么是 if err != nil
Go 的错误处理通常长这样:
value, err := doSomething()
if err != nil {
return err
}
// 只有没有错误时,才继续使用 value。
这不是模板代码的装饰,而是在表达一个很明确的流程:
调用函数
检查错误
如果有错误,处理或返回
如果没有错误,继续执行
Go 希望错误路径是显式的。
显式的好处是:
-
你能清楚看到每一步可能失败
-
你能在失败处补充上下文
-
你能决定是重试、忽略、返回、记录日志,还是终止程序
-
代码不会突然跳到远处的 catch 块
这也是 Go 风格里很重要的一点:
错误处理是正常业务流程的一部分。
三、nil 表示没有错误
在 Go 里,nil 通常表示没有错误。
例如:
func save(name string) error {
if name == "" {
return errors.New("name is empty")
}
// 保存成功,没有错误。
return nil
}
调用方这样判断:
err := save("alice")
if err != nil {
fmt.Println("save failed:", err)
return
}
fmt.Println("save success")
这就是 Go 里最常见的错误处理模式。
新手可以先记住:
err == nil 表示成功
err != nil 表示失败
四、不要忽略错误
新手有时会这样写:
result, _ := divide(10, 0)
fmt.Println(result)
这里的 _ 表示丢弃错误。
这在语法上可以,但在业务上通常很危险。
如果你忽略错误,就等于告诉程序:
即使失败了,我也不关心。
但很多错误是必须处理的:
-
文件不存在
-
网络请求失败
-
JSON 解析失败
-
数据库写入失败
-
参数不合法
-
权限不足
除非你非常确定这个错误可以忽略,否则不要随手写 _。
更好的写法是:
result, err := divide(10, 0)
if err != nil {
fmt.Println("divide failed:", err)
return
}
fmt.Println(result)
五、创建错误:errors.New
最简单的创建错误方式是 errors.New。
package main
import (
"errors"
"fmt"
)
func checkAge(age int) error {
if age < 0 {
return errors.New("age cannot be negative")
}
if age < 18 {
return errors.New("age must be at least 18")
}
return nil
}
func main() {
if err := checkAge(15); err != nil {
fmt.Println("invalid age:", err)
return
}
fmt.Println("age is valid")
}
输出:
invalid age: age must be at least 18
errors.New 适合创建固定文本的错误。
如果错误里要带变量,就更常用 fmt.Errorf。
六、创建带变量的错误:fmt.Errorf
fmt.Errorf 可以像 fmt.Sprintf 一样格式化错误信息。
package main
import (
"fmt"
)
func findUser(id int) error {
if id <= 0 {
return fmt.Errorf("invalid user id: %d", id)
}
return nil
}
func main() {
if err := findUser(-1); err != nil {
fmt.Println(err)
}
}
输出:
invalid user id: -1
相比:
errors.New("invalid user id")
fmt.Errorf 可以把具体值放进去,让排查问题更方便。
七、错误信息应该怎么写
错误信息不是越长越好。
好的错误信息应该:
-
说明哪里失败
-
尽量带上关键上下文
-
不要首字母大写
-
不要以句号结尾
-
不要写成用户界面的提示语
例如:
return fmt.Errorf("open config %q: %w", path, err)
比下面这种更好:
return fmt.Errorf("Error! Something went wrong.")
Go 里错误常常会被一层层包装,最后组成一句完整信息。
例如:
load config "app.yaml": open file: permission denied
如果每一层都写成大写开头、感叹号、句号,最后就会很别扭。
八、添加上下文:不要只原样返回错误
假设你写了这样一个函数:
func loadConfig(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
_ = data
return nil
}
它能工作,但调用方看到错误时,可能只知道:
open config.yaml: no such file or directory
如果项目里有很多地方都读文件,就不容易知道这次失败发生在哪个业务步骤。
更推荐给错误加上下文:
func loadConfig(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read config %q: %w", path, err)
}
_ = data
return nil
}
注意这里用了 %w。
fmt.Errorf("read config %q: %w", path, err)
%w 表示包装一个错误。
包装以后:
-
错误信息会带上上下文
-
原始错误仍然可以被
errors.Is或errors.As找到
完整例子:
package main
import (
"fmt"
"os"
)
func loadConfig(path string) error {
data, err := os.ReadFile(path)
if err != nil {
// %w 会包装原始错误,保留错误链。
return fmt.Errorf("read config %q: %w", path, err)
}
fmt.Println("config size:", len(data))
return nil
}
func main() {
if err := loadConfig("missing.yaml"); err != nil {
fmt.Println(err)
}
}
可能输出:
read config "missing.yaml": open missing.yaml: no such file or directory
这比单独返回底层错误更有用。
九、错误包装是什么
错误包装可以理解成:
在原始错误外面套一层上下文。
例如:
open missing.yaml: no such file or directory
被包装后变成:
read config "missing.yaml": open missing.yaml: no such file or directory
如果再往上包装:
start server: load app: read config "missing.yaml": open missing.yaml: no such file or directory
每一层都告诉你:
我在做什么的时候失败了。
这对排查问题很重要。
十、errors.Is:判断错误是不是某个错误
有时你不只想打印错误,而是想判断错误类型。
例如:
如果文件不存在,就创建默认配置。
如果是权限错误,就直接返回。
Go 推荐用 errors.Is 来判断错误链里是否包含某个目标错误。
package main
import (
"errors"
"fmt"
"os"
)
func loadConfig(path string) error {
_, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read config %q: %w", path, err)
}
return nil
}
func main() {
err := loadConfig("missing.yaml")
if err == nil {
fmt.Println("config loaded")
return
}
if errors.Is(err, os.ErrNotExist) {
fmt.Println("config does not exist, use default config")
return
}
fmt.Println("load config failed:", err)
}
输出:
config does not exist, use default config
为什么不用字符串判断?
不要这样写:
if strings.Contains(err.Error(), "no such file") {
// ...
}
原因是:
-
错误文本可能变化
-
不同系统上的错误文本可能不同
-
字符串匹配容易误判
-
包装错误后文本更复杂
errors.Is 是结构化判断,比字符串判断可靠。
十一、哨兵错误 sentinel error
哨兵错误是预先定义好的固定错误值。
例如:
var ErrNotFound = errors.New("not found")
调用方可以用 errors.Is 判断:
if errors.Is(err, ErrNotFound) {
// 处理未找到
}
完整例子:
package main
import (
"errors"
"fmt"
)
var ErrUserNotFound = errors.New("user not found")
func findUserName(id int) (string, error) {
if id == 100 {
return "Alice", nil
}
return "", fmt.Errorf("find user %d: %w", id, ErrUserNotFound)
}
func main() {
name, err := findUserName(200)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
fmt.Println("show empty user page")
return
}
fmt.Println("find user failed:", err)
return
}
fmt.Println("user:", name)
}
输出:
show empty user page
哨兵错误适合表达稳定、可判断的错误状态。
常见例子:
-
io.EOF -
os.ErrNotExist -
context.Canceled -
context.DeadlineExceeded
但不要给每一个错误都创建哨兵错误。
如果调用方不需要专门判断,就普通返回错误即可。
十二、errors.As:取出某种错误类型
errors.Is 用来判断"是不是某个错误"。
errors.As 用来判断"错误链里有没有某种错误类型",并把它取出来。
例如你定义了一个带字段的错误类型:
type ValidationError struct {
Field string
Value string
}
func (e ValidationError) Error() string {
return fmt.Sprintf("invalid %s: %q", e.Field, e.Value)
}
调用方可能不只想知道"验证失败",还想知道哪个字段失败。
完整例子:
package main
import (
"errors"
"fmt"
)
type ValidationError struct {
Field string
Value string
}
func (e ValidationError) Error() string {
return fmt.Sprintf("invalid %s: %q", e.Field, e.Value)
}
func validateName(name string) error {
if name == "" {
return ValidationError{
Field: "name",
Value: name,
}
}
return nil
}
func createUser(name string) error {
if err := validateName(name); err != nil {
return fmt.Errorf("create user: %w", err)
}
return nil
}
func main() {
err := createUser("")
if err == nil {
fmt.Println("user created")
return
}
var validationErr ValidationError
if errors.As(err, &validationErr) {
fmt.Println("field:", validationErr.Field)
fmt.Println("value:", validationErr.Value)
return
}
fmt.Println("create user failed:", err)
}
输出:
field: name
value:
注意 errors.As 的第二个参数:
errors.As(err, &validationErr)
这里要传目标变量的地址。
十三、自定义错误类型
当错误不只是一个字符串,而是需要携带结构化信息时,可以定义自己的错误类型。
例如:
package main
import (
"fmt"
"time"
)
type RateLimitError struct {
RetryAfter time.Duration
}
func (e RateLimitError) Error() string {
return fmt.Sprintf("rate limited, retry after %s", e.RetryAfter)
}
func callAPI() error {
return RateLimitError{
RetryAfter: 2 * time.Second,
}
}
func main() {
err := callAPI()
if err != nil {
fmt.Println(err)
}
}
输出:
rate limited, retry after 2s
自定义错误类型适合:
-
需要暴露错误分类
-
需要携带字段
-
调用方需要根据字段做不同处理
如果只是简单描述失败原因,errors.New 或 fmt.Errorf 就够了。
十四、errors.Join:合并多个错误
有时一个操作可能同时产生多个错误。
例如关闭多个资源时,可能每个资源都关闭失败。
Go 的 errors.Join 可以把多个错误合并成一个错误。
package main
import (
"errors"
"fmt"
)
func main() {
err1 := errors.New("close file failed")
err2 := errors.New("close network failed")
err := errors.Join(err1, err2)
if err != nil {
fmt.Println(err)
}
}
可能输出:
close file failed
close network failed
errors.Join 会忽略 nil 错误。
如果传进去的错误全是 nil,它会返回 nil。
示例:
package main
import (
"errors"
"fmt"
)
func main() {
err := errors.Join(nil, nil)
fmt.Println(err == nil)
}
输出:
true
在新手阶段,你不一定经常用到 errors.Join,但知道它可以表达"多个错误同时存在"就够了。
十五、defer 和错误处理
错误处理经常和 defer 一起出现。
defer 用来注册函数结束前要执行的操作,常见于释放资源。
例如:
package main
import (
"fmt"
"os"
)
func readFile(path string) error {
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("open file %q: %w", path, err)
}
defer file.Close()
buffer := make([]byte, 16)
_, err = file.Read(buffer)
if err != nil {
return fmt.Errorf("read file %q: %w", path, err)
}
return nil
}
func main() {
tmp, err := os.CreateTemp("", "go-error-demo-*.txt")
if err != nil {
fmt.Println("create temp file:", err)
return
}
defer os.Remove(tmp.Name())
if _, err := tmp.WriteString("hello go"); err != nil {
fmt.Println("write temp file:", err)
return
}
tmp.Close()
if err := readFile(tmp.Name()); err != nil {
fmt.Println("read failed:", err)
return
}
fmt.Println("read success")
}
输出:
read success
defer file.Close() 的意思是:
不管 readFile 后面是成功返回,还是因为错误提前返回,都要关闭文件。
十六、关闭资源时的错误要不要处理
很多人会写:
defer file.Close()
这很常见,但有一个细节:
Close 本身也可能返回错误。
如果你写的是只读文件,忽略 Close 错误通常问题不大。
但如果你写文件,Close 时可能才发现刷盘失败。这时最好处理关闭错误。
示例:
package main
import (
"fmt"
"os"
)
func writeReport(path string, content string) (err error) {
file, err := os.Create(path)
if err != nil {
return fmt.Errorf("create report %q: %w", path, err)
}
defer func() {
closeErr := file.Close()
if closeErr != nil && err == nil {
err = fmt.Errorf("close report %q: %w", path, closeErr)
}
}()
if _, err := file.WriteString(content); err != nil {
return fmt.Errorf("write report %q: %w", path, err)
}
return nil
}
func main() {
tmp, err := os.CreateTemp("", "report-*.txt")
if err != nil {
fmt.Println("create temp file:", err)
return
}
path := tmp.Name()
tmp.Close()
defer os.Remove(path)
if err := writeReport(path, "hello report"); err != nil {
fmt.Println("write report failed:", err)
return
}
fmt.Println("write report success")
}
这里用了命名返回值:
func writeReport(path string, content string) (err error)
defer 里可以看到即将返回的 err,并在需要时补上关闭错误。
这个写法对新手来说稍微绕一点。先理解思路就好:
如果 Close 的错误很重要,就不要完全忽略它。
十七、panic 不是普通错误处理
Go 里还有 panic。
panic 会停止当前函数的正常执行,并开始展开调用栈。已经注册的 defer 会执行。
示例:
package main
import "fmt"
func main() {
defer fmt.Println("defer runs")
fmt.Println("before panic")
panic("something is broken")
}
输出大致会是:
before panic
defer runs
panic: something is broken
程序会异常退出。
那什么时候用 panic?
新手可以先记住:
普通可预期错误用 error。
程序无法继续、违反内部不变量时,才考虑 panic。
例如:
-
用户输入错误:返回
error -
文件不存在:返回
error -
网络超时:返回
error -
配置格式错误:返回
error -
数组越界、空指针、不可恢复的内部状态:可能触发
panic
不要把 panic 当成 try/catch 的替代品。
十八、recover:从 panic 中恢复
recover 可以在 defer 函数中捕获 panic,让程序恢复控制。
示例:
package main
import "fmt"
func safeRun() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
}
}()
fmt.Println("before panic")
panic("boom")
}
func main() {
safeRun()
fmt.Println("program continues")
}
输出:
before panic
recovered: boom
program continues
recover 只能在 deferred function 里直接调用才有效。
但是注意:
recover 不是让你随便吞掉所有 panic。
更常见的使用场景是:
-
HTTP 服务器中间件兜底,避免单个请求导致整个服务退出
-
goroutine 顶层保护,记录 panic 日志
-
框架边界把 panic 转成错误响应
业务逻辑里的普通失败,仍然应该返回 error。
十九、把 panic 转成 error
有时你调用的代码可能 panic,但你希望函数对外返回 error。
可以这样写:
package main
import (
"fmt"
)
func riskyDivide(a, b int) int {
if b == 0 {
panic("division by zero")
}
return a / b
}
func safeDivide(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("safe divide failed: %v", r)
}
}()
result = riskyDivide(a, b)
return result, nil
}
func main() {
result, err := safeDivide(10, 0)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(result)
}
输出:
safe divide failed: division by zero
这类写法应该放在边界位置,不应该让整个项目都依赖 panic/recover 做普通流程控制。
二十、错误应该在哪里处理
当一个函数返回错误时,调用方有几种选择:
1. 直接处理
例如文件不存在时使用默认配置:
if errors.Is(err, os.ErrNotExist) {
useDefaultConfig()
return nil
}
2. 添加上下文后继续返回
例如底层读文件失败,上层说明自己正在加载配置:
return fmt.Errorf("load config: %w", err)
3. 转换成业务错误
例如数据库没找到用户,转换成业务层的 ErrUserNotFound:
return fmt.Errorf("get profile: %w", ErrUserNotFound)
4. 记录日志并终止流程
例如 main 函数里:
if err := run(); err != nil {
log.Fatal(err)
}
新手最容易犯的错误是:每一层都打印日志,然后又继续返回错误。
例如:
if err != nil {
log.Println(err)
return err
}
如果很多层都这么写,最后日志里会重复出现一堆相似错误。
更清楚的做法通常是:
底层返回错误
中间层添加上下文
最外层统一记录日志
二十一、实战例子:读取配置并处理错误
下面写一个稍微完整的例子。
需求:
读取配置文件。
如果文件不存在,使用默认配置。
如果文件为空,返回验证错误。
如果读取失败,保留底层错误。
完整代码:
package main
import (
"errors"
"fmt"
"os"
"strings"
)
var ErrEmptyConfig = errors.New("empty config")
type Config struct {
AppName string
}
func parseConfig(content string) (Config, error) {
content = strings.TrimSpace(content)
if content == "" {
return Config{}, ErrEmptyConfig
}
return Config{AppName: content}, nil
}
func loadConfig(path string) (Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return Config{}, fmt.Errorf("read config %q: %w", path, err)
}
config, err := parseConfig(string(data))
if err != nil {
return Config{}, fmt.Errorf("parse config %q: %w", path, err)
}
return config, nil
}
func defaultConfig() Config {
return Config{AppName: "demo-app"}
}
func main() {
config, err := loadConfig("missing.conf")
if err != nil {
if errors.Is(err, os.ErrNotExist) {
config = defaultConfig()
fmt.Println("config file missing, use default config")
fmt.Println("app name:", config.AppName)
return
}
if errors.Is(err, ErrEmptyConfig) {
fmt.Println("config file is empty")
return
}
fmt.Println("load config failed:", err)
return
}
fmt.Println("app name:", config.AppName)
}
输出:
config file missing, use default config
app name: demo-app
这个例子里有几个关键点:
-
parseConfig只负责解析,不负责读文件。 -
loadConfig给读文件和解析错误加上下文。 -
main决定怎么处理不同错误。 -
判断错误时用
errors.Is,而不是字符串匹配。 -
文件不存在是可恢复错误,所以使用默认配置。
这就是比较典型的 Go 错误处理风格。
二十二、常见错误处理模式
早返回
Go 里很常见的写法是:
if err != nil {
return err
}
这叫早返回。
好处是:错误路径先处理,正常路径不用包在很深的 else 里。
不要写成:
if err == nil {
// 一大段正常逻辑
} else {
return err
}
更推荐:
if err != nil {
return err
}
// 一大段正常逻辑
包装后返回
跨函数返回错误时,给错误加上下文:
if err != nil {
return fmt.Errorf("save user %d: %w", userID, err)
}
判断特殊错误
使用 errors.Is:
if errors.Is(err, os.ErrNotExist) {
// 文件不存在
}
提取错误类型
使用 errors.As:
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Println("operation:", pathErr.Op)
fmt.Println("path:", pathErr.Path)
}
二十三、常见误区
误区一:用 panic 处理普通错误
错误写法:
if err != nil {
panic(err)
}
如果这是普通文件读取、网络请求、参数校验,就不应该 panic。
更好的写法:
if err != nil {
return fmt.Errorf("read input: %w", err)
}
误区二:错误信息没有上下文
不太好:
return err
更好:
return fmt.Errorf("load user profile: %w", err)
当然也不是每一层都必须包装。关键是让最终错误信息能说明失败路径。
误区三:用字符串判断错误
不推荐:
if err.Error() == "not found" {
// ...
}
更推荐:
if errors.Is(err, ErrNotFound) {
// ...
}
误区四:吞掉错误
不推荐:
doSomething()
如果函数返回错误,应该接住:
if err := doSomething(); err != nil {
return err
}
误区五:重复打印同一个错误
底层打印一次,中间层打印一次,最外层又打印一次,会让日志变乱。
通常只在边界层记录日志,例如:
-
main -
HTTP handler
-
goroutine 顶层
-
CLI 命令入口
中间层优先返回带上下文的错误。
二十四、新手错误处理检查清单
写 Go 代码时,可以用这张清单检查:
-
函数会失败吗?如果会,是否返回
error? -
调用函数后,是否检查了
err != nil? -
返回错误时,是否保留了原始错误?
-
需要跨层传递时,是否用
%w包装? -
判断错误时,是否使用
errors.Is或errors.As? -
是否避免了用字符串匹配错误?
-
是否只在真正异常的情况下使用
panic? -
打开文件、连接等资源后,是否用
defer释放? -
关闭资源的错误是否重要?如果重要,是否处理了?
-
日志是否只在合适的边界层打印?
二十五、学习路线建议
如果你是新手,可以按这个顺序练:
-
写一个返回
error的函数。 -
用
errors.New创建固定错误。 -
用
fmt.Errorf创建带变量的错误。 -
用
if err != nil做早返回。 -
用
%w包装错误。 -
用
errors.Is判断哨兵错误。 -
定义一个自定义错误类型。
-
用
errors.As取出自定义错误。 -
用
defer释放资源。 -
理解
panic/recover,但不要滥用。
这些练顺以后,Go 的错误处理就不再只是"满屏 if err != nil",而是一套很清晰的失败处理机制。
总结
Go 的错误处理可以压缩成几句话:
-
error是一个接口,核心方法是Error() string。 -
nil表示没有错误。 -
普通失败应该返回
error,不要用panic。 -
errors.New创建固定错误。 -
fmt.Errorf创建格式化错误。 -
%w用来包装错误,保留错误链。 -
errors.Is用来判断错误链里是否有某个错误。 -
errors.As用来取出错误链里的某种错误类型。 -
errors.Join可以合并多个错误。 -
defer常用于释放资源。 -
recover只适合在边界位置处理 panic。 -
底层返回错误,中间层加上下文,边界层统一记录日志。
最后记住一句:
Go 的错误处理不是为了少写代码,而是为了让失败路径清楚可见。
当你能清楚回答"这个错误在哪里产生、在哪里补充上下文、在哪里被处理",你就真正开始掌握 Go 的错误处理了。