Go 错误处理机制详解:新手从 err != nil 到 errors.Is/As
刚开始学 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的零值是0string的零值是""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 18errors.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.EOFos.ErrNotExistcontext.Canceledcontext.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 failederrors.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 successdefer 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 continuesrecover只能在 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 }误区五:重复打印同一个错误
底层打印一次,中间层打印一次,最外层又打印一次,会让日志变乱。
通常只在边界层记录日志,例如:
mainHTTP 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 的错误处理了。
参考资料
Go Blog: Errors are values
Go Blog: Working with Errors in Go 1.13
Package errors
Package fmt: Errorf
Builtin package: error, panic, recover
Go Blog: Defer, Panic, and Recover
Effective Go: Errors
