Goのエラーハンドリングは、例外を投げずに error 型の値を戻り値で返し、呼び出し側が if err != nil で確かめる、という仕組みだけでできています。文法は単純ですが、実務で迷うのは運用です。:= で err を取り違えない書き方、%w でラップする場所、errors.Is と errors.As(Go 1.26で追加された errors.AsType)の使い分け、panic を許す範囲を、Go 1.27.2(darwin/amd64)で実行した結果と一緒に整理します。掲載したコードは import などを省いた断片を含みますが、必要な宣言を補って go run と go vet で検証し、出力はその実行結果です。誤用例は、意図して起こしたコンパイルエラー・panic・vetの指摘をそのまま載せています。
まとめ:Goのエラーハンドリングの要点
if err != nilは普通のif文です。失敗したら文脈を足してすぐ返し、正常系を左端に並べます。- 内側のブロックで
:=を書くと別のerrができます。外側のerrがnilのまま返っても、コンパイラもgo vetも報告しません。戻り値のerrorを丸ごと捨てた呼び出しも同じで、errcheck(golangci-lint の既定)で拾います。 - ラップは
%wで行います。%vで埋め込むとerrors.Isがfalseになります。Go 1.27のgo vetは、%wにエラー型のポインタを誤って渡した呼び出しも報告します。 - 値の一致は
errors.Is、型の取り出しはerrors.AsType。errors.Asの引数の誤りは実行時panicですが、errors.AsTypeはコンパイルで落ち、実測で約9倍速くアロケーションも0でした。 - panic は想定内の失敗には使いません。パッケージ内部のエラー伝播に使うpanicだけを、型で見分けて境界で error に変えます。別のgoroutineのpanicは呼び出し元でrecoverできません。
- 書き込み用ファイルの
Close()の戻り値は捨てません。名前付き戻り値とerrors.Joinで返します。 if err != nilを省く新しい構文は来ません。Goチームは2025年6月に構文変更を当面追わないと表明しており、判定を減らすのは設計(errWriter・cmp.Or)の役目です。
Goのエラーは戻り値で返す値
errorインターフェースと自作エラー型
error は Error() string だけを持つ組み込みのインターフェースです。関数は成功したらnil、失敗したらnil以外を返す約束で、err != nil はその約束を見ているだけです。仕組みはGoのインターフェースとは?暗黙実装とダックタイピング・型制約を実装例で解説で扱っています。
type error interface {
Error() string
}
type ValidationError struct {
Field string
}
func (e *ValidationError) Error() string {
return "invalid field: " + e.Field
}
呼び出し側でフィールドを読みたいエラーは、文字列だけでなく構造体にします。レシーバをポインタにした場合、error を満たすのは *ValidationError で、ValidationError ではありません。この区別が後の errors.As / errors.AsType の書き方を決めます。
if err != nil の2つの書き方と早期リターン
Go WikiのCodeReviewCommentsは「Do not discard errors using _ variables.」とし、処理する・返す・本当に例外的な場合だけpanicする、の3択を示しています。同じ文書の「Indent Error Flow」は、エラー処理を先に書いて抜け、正常系の字下げを浅く保つよう求めています。
func loadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("設定の読み込み: %w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("設定 %s のJSONが不正: %w", path, err)
}
return &cfg, nil
}
設定の読み込み: open missing.json: no such file or directory
fs.ErrNotExist: true
設定 bad.json のJSONが不正: invalid character 'p' looking for beginning of object key string
2つの書き方の違いは err が見える範囲です。
| 書き方 | errのスコープ | 向く場面 |
|---|---|---|
data, err := f() の次行で判定 |
同じブロックの末尾まで | 成功時の値を後で使う |
if err := f(); err != nil { |
if文の中だけ | 戻り値がerrorだけ |
os.ReadFile の data は次の行で使うので代入してから判定し、error しか返さない json.Unmarshal は初期化文の形でスコープを閉じています。失敗時に返す第1戻り値はゼロ値(ここでは nil)にそろえます。作りかけの値を返すと、呼び出し元がエラーを見落としたときに壊れた値が先へ流れます。
:=の再宣言とシャドーイングでerrを取り違える落とし穴
同じブロックでの再宣言ルール
1つの関数で x, err := … を何度も書けるのは、言語仕様の再宣言ルールのおかげです。条件は「originally declared earlier in the same block … with the same type, and at least one of the non-blank variables is new」で、同じブロックで宣言済みかつ左辺に新しい変数が1つ以上あるときだけ、err は上書きされます。新しい変数が無ければコンパイルエラーです。
f, err := os.Open("a.txt")
f, err := os.Open("b.txt")
./main.go:7:9: no new variables on left side of :=
Goブログの2025年6月の記事は、このルールがエラー処理のために導入されたと書いています。
内側のブロックのシャドーイングとshadowによる検出
「同じブロック」を外れると、:= は外側の err を隠す新しい変数を作ります。
func run(retry bool) error {
n, err := step1()
if err != nil {
return err
}
if retry {
n, err := step2(n) // 内側のブロックで n と err を新しく宣言してしまう
_ = n
if err != nil {
fmt.Println("inner err:", err)
}
}
return err // 外側の err(nil)が返る
}
inner err: step2 failed
run: <nil>
内側では失敗を見ているのに、関数はnilを返しています。コンパイルは通り、go vet も何も出しません。既定の解析にシャドーイングの検査が入っていないためです。golang.org/x/tools の shadow アナライザー(v0.52.0)を別に実行すると見つかります。
go install golang.org/x/tools/go/analysis/passes/shadow/cmd/[email protected]
shadow ./...
main.go:19:3: declaration of "n" shadows declaration at line 14
main.go:19:6: declaration of "err" shadows declaration at line 14
直し方は、内側を n, err = step2(n) の代入にするか、内側で失敗したらその場で return することです。shadowは意図した再宣言も報告するので、CIの必須にするより、レビュー時に疑わしい箇所を探す道具に向きます。
戻り値のerrorを捨てた呼び出しとerrcheck
受け取った err を一度も使わなければ「declared and not used」でコンパイルが止まりますが、err を受け取らずに呼んだ場合は何も言われません。次の2行はコンパイルも go vet も通ります。
f.WriteString("hello\n") // 戻り値の error を捨てている
f.Close() // 同上
これを拾うのが errcheck です。v1.30.0(2026年10月10日公開)の出力です。
main.go:10:15: f.WriteString("hello\n") // 戻り値の error を捨てている
main.go:11:9: f.Close() // 同上
golangci-lint v2.15.0(2026年10月16日公開)は、設定を変えなければ errcheck・govet・ineffassign・staticcheck・unused の5つが有効で、errcheck を含みます。errcheck は空識別子 _ への代入を既定では検査しないので、捨てた箇所まで洗い出すときは errcheck -blank ./... で実行します。
fmt.Errorfの%wによるラップとerrors.Joinによる集約
%wと%vによるerrors.Isの判定結果の違い
文脈を足して上へ返すとき、%w はラップしてチェーンを残し、%v は文字列として埋め込むだけです。メッセージが同じでも判定は逆になります。
var ErrNotFound = errors.New("user not found")
func findUser(id string) error {
if id == "" {
return fmt.Errorf("findUser(%q): %w", id, ErrNotFound)
}
return nil
}
func main() {
err := findUser("")
fmt.Println("err :", err)
fmt.Println("errors.Is :", errors.Is(err, ErrNotFound))
// %v で埋め込むとチェーンが切れる
broken := fmt.Errorf("findUser: %v", ErrNotFound)
fmt.Println("ラップなし :", errors.Is(broken, ErrNotFound))
}
err : findUser(""): user not found
errors.Is : true
ラップなし : false
ログで見ると両方とも正しく見えるので、テストでも見落とされます。下位のエラーを呼び出し元に判定させたいなら %w です。内部実装の型を公開APIに漏らしたくないときだけ、意図して %v を選びます。
errors.Joinによる複数エラーの集約
Go 1.20で追加された errors.Join は、複数のエラーを1つの error にします。入力チェックで全件の誤りを返したい場面や、後で説明するdeferでの合流に使います。
joined := errors.Join(ErrNotFound, os.ErrPermission)
fmt.Println("Join Is(NotFound) :", errors.Is(joined, ErrNotFound))
fmt.Println("Join Is(Permission):", errors.Is(joined, os.ErrPermission))
fmt.Printf("Join の文字列表現:\n%v\n", joined)
Join Is(NotFound) : true
Join Is(Permission): true
Join の文字列表現:
user not found
permission denied
束ねた後も、どちらのセンチネルにも errors.Is はtrueです。文字列は改行区切りになるので、1行1レコードのログ基盤にそのまま流すと記録が割れます。同じGo 1.20から fmt.Errorf も %w を複数書けます。
ラップ時のメッセージ二重化
標準ライブラリの多くは、操作名と対象を含むエラーを返します。os.Open の *fs.PathError は「open <path>: …」の形なので、呼び出し側でパスを足すと重複します。
// 修正前:os.Open のエラーが既に "open <path>" を含む
return fmt.Errorf("open %s: %w", path, err)
// 修正後:呼び出し元しか知らない文脈だけを足す
return fmt.Errorf("設定の読み込み: %w", err)
修正前: open /tmp/missing.json: open /tmp/missing.json: no such file or directory
修正後: 設定の読み込み: open /tmp/missing.json: no such file or directory
ラップで足すのは、下位が知らない情報(何のために開いたか)だけにします。各層がこの線を守れば、層を重ねても同じ語が繰り返されず、原因まで左から右へ読めます。
Go 1.27のvetが報告する%wへのポインタ渡し
Go 1.27のリリースノートによると、vetのprintf解析は、型 E 自体が error を実装しているのに %w へ *E を渡した呼び出しを報告するようになりました。値レシーバで Error() を書いた型を & 付きで包むと、型で探す判定が静かに外れます。
type QuotaError struct{ Limit int }
func (e QuotaError) Error() string { return fmt.Sprintf("quota exceeded: %d", e.Limit) }
func check(n int) error {
if n > 10 {
qe := &QuotaError{Limit: 10}
return fmt.Errorf("check: %w", qe) // 値型で error を実装した型のポインタを包んでいる
}
return nil
}
$ go run .
check: quota exceeded: 10
AsType[QuotaError] : false
AsType[*QuotaError]: true
Is(QuotaError{10}) : false
$ go vet .
main.go:15:29: %w wants operand of error type QuotaError, not pointer type *QuotaError (defeats errors.Is)
呼び出し元が QuotaError で判定すると、メッセージは正しく出ているのに一致しません。go test が既定で走らせるvetにprintf検査は含まれるので、Go 1.27に上げてテストを回すと既存コードのこの誤りが表に出ます。直すときは & を外して値で包みます。Go 1.27のほかの変更はGo 1.27の新機能・変更点まとめ|Goの最新バージョンは1.27、確認・更新方法と移行の注意点にまとめています。
errors.Is・errors.As・errors.AsTypeの使い分けと実測差
値の一致を見るerrors.Isと型を取り出すerrors.As
「このエラーか?」を訊くのが errors.Is、「この型として中身を読みたい」が errors.As です。どちらもチェーンを辿ります。
err := fmt.Errorf("save: %w", &ValidationError{Field: "email"})
var ve *ValidationError
if errors.As(err, &ve) { // ポインタのポインタを渡す
fmt.Println("As :", ve.Field) // As : email
}
errors.Asの引数の誤りによる実行時panic
errors.As の第2引数は any 型なので、型の整合はコンパイラが検査しません。& を取り忘れて値を渡してもビルドは通り、実行時に落ちます。
var ve ValidationError
fmt.Println(errors.As(err, ve)) // ポインタを取り忘れている
panic: errors: target must be a non-nil pointer
見つけられるのは go vet です。
main.go:19:14: second argument to errors.As must be a non-nil pointer to either a type that implements error, or to any interface type
この errorsas 検査も go test の既定のvetに入っています。逆に言えば、vetもテストもCIに無いプロジェクトでは本番まで残ります。エラー経路は成功ケースのテストでは通らないので、静的検査に加えて、失敗を再現するテストも用意します。
errors.AsType(Go 1.26)のコンパイル時検査と実測値
Go 1.26のリリースノートは、新しい AsType を「a generic version of As. It is type-safe, faster, and, in most cases, easier to use」と説明しています。シグネチャは func AsType[E error](err error) (E, bool) です。
if ve, ok := errors.AsType[*ValidationError](err); ok {
fmt.Println("AsType:", ve.Field) // AsType: email
}
型引数に値型 ValidationError を書くと、実行前にコンパイラが止めます。
./main.go:18:29: ValidationError does not satisfy error (method Error has pointer receiver)
3段ラップしたエラーに対し、errors.As はループの中で受け取り変数を宣言し、errors.AsType は成否だけを使う形で、-benchmem -benchtime=2s -count=5 を取った結果です(Go 1.27.2 / Intel Core i9-9880H)。
| 関数 | ns/op | B/op | allocs/op |
|---|---|---|---|
| errors.As | 249.1〜257.8 | 8 | 1 |
| errors.AsType | 27.68〜29.23 | 0 | 0 |
8バイトの確保は、&ve を any として渡した受け取り変数がヒープへ逃げるためです。変数をループの外で1回だけ宣言すると、別の計測で確保は0になりましたが、時間は330〜342ns/opで errors.AsType(同じ計測で42〜43ns/op)の約8倍のままでした。差の大半は、errors.As がリフレクションで引数の型を調べる処理です。Go 1.26以降を前提にできるコードでは errors.AsType を既定にしてよいと判断します。errors.As を残すのは、Go 1.25以前でもビルドされるライブラリを書くときと、interface{ Timeout() bool } のように error を含まないインターフェースで探したいときです(AsType では interface{ error; Timeout() bool } と error を埋め込めば同じ判定ができました)。リリースポリシーでは各版は新しい版が2つ出るまでサポートされ、2026年11月時点の現行は1.27.3と1.26.10なので、サポート中の版はどちらも AsType を使えます。
panicとrecoverを使ってよい境界
recoverでpanicをerrorへ変換する境界関数
panic は例外の代わりではありません。範囲外アクセス・nil参照・整数のゼロ除算ではランタイムがpanicを起こします。ファイルが無い、入力が不正、通信が切れた、は想定内の失敗なので error で返します。一方で標準ライブラリには、深い再帰の途中から抜けるためにpanicを内部のエラー伝播に使う実装もあります。encoding/json のエンコーダは、自分の型 jsonError だけをrecoverして error に戻し、それ以外は panic(r) で投げ直しています(encode.go)。recoverで変換するのは、このように自分で起こしたと分かるpanicに絞ります。下の safeDivide は、変換の書き方を見るための最小例です。
func safeDivide(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("safeDivide: %v", r)
}
}()
return a / b, nil
}
// 戻り値に名前を付けないと、defer での代入が呼び出し元に届かない
func unnamed(a, b int) (int, error) {
var err error
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("unnamed: %v", r)
}
}()
return a / b, err
}
5 <nil>
0 safeDivide: runtime error: integer divide by zero
0 <nil>
3行目が unnamed(10, 0) です。panicは止まりましたが err はnilのまま返り、呼び出し元は失敗に気づけません。recoverで変換するときは、戻り値に名前を付けてdefer内で代入します。なお Go 1.21以降の標準設定では、panic(nil) を回復した recover は nil ではなく *runtime.PanicNilError を返すので、r != nil の判定で取りこぼしません(GODEBUG=panicnil=1 で旧動作に戻した場合はnilです)。
別goroutineのpanicとrecoverの適用範囲
recover は、panicしたのと同じgoroutineのdeferでしか効きません。
func main() {
defer func() {
fmt.Println("main の recover:", recover())
}()
go func() { panic("worker failed") }()
select {}
}
panic: worker failed
goroutine 19 [running]:
main.main.func2()
/tmp/main.go:9 +0x25
created by main.main in goroutine 1
/tmp/main.go:9 +0x37
exit status 2
main のrecoverは一度も呼ばれず、プロセスごと終了しました(goroutine番号は実行ごとに変わります)。HTTPハンドラにrecoverを入れていても、そこから起動したgoroutineのpanicでサーバーは落ちます。回収するには、起動するgoroutineの内側にdeferを置きます。
go func() {
defer close(done)
defer func() {
if r := recover(); r != nil {
fmt.Println("goroutine 側で回収:", r)
}
}()
panic(errors.New("worker failed"))
}()
この形なら「goroutine 側で回収: worker failed」が出てmainは生き残ります。ただし表示して終わりにすると、失敗が誰にも伝わりません。共通ヘルパーでrecoverするなら、エラーとして呼び出し元へ返し、スタックをログに残します。共有状態を書き換えている途中のpanicのように整合性を保証できないものは、回収せずにプロセスを止めるほうが安全です。goroutineの仕組みはゴルーチン(goroutine)とは?仕組み・使い方と同時実行数の制限をGo 1.27で解説で扱っています。
deferのClose失敗と名前付き戻り値による伝播
書き込み用のファイルでは、OSやファイルシステム(NFSなど)によって、書き込みの失敗が Close の時点で報告されることがあります。defer f.Close() で捨てると、成功扱いなのにデータが欠けます。bufio.Writer を挟む場合は、Close の前に Flush を呼んでその戻り値も確かめます。
func writeFile(path string) (err error) {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("出力先の作成: %w", err)
}
defer func() {
if cerr := f.Close(); cerr != nil {
err = errors.Join(err, fmt.Errorf("クローズ: %w", cerr))
}
}()
if _, err := f.WriteString("hello"); err != nil {
return fmt.Errorf("本文の書き込み: %w", err)
}
return nil
}
Closeの失敗を作るために、同じ形の関数の中で先に1回 f.Close() させた結果です。
closed twice: クローズ: close /tmp/out2.txt: file already closed
return nil の後でdeferが err を上書きし、Closeの失敗が呼び出し元まで届いています。読み取り専用で開いたファイルは defer f.Close() のままで構いません。この形にするのは書き込みを伴うリソースだけです。
errors.Isによるリトライ判定とcontextによる打ち切り
外部APIの呼び出しでは、再試行してよい失敗と無意味な失敗を分けます。判定をセンチネルとの errors.Is で書くと、ラップの深さに左右されません。
func retryable(err error) bool {
return errors.Is(err, ErrRateLimited)
}
func withRetry(ctx context.Context, max int) error {
if max < 1 {
return fmt.Errorf("max must be positive: %d", max)
}
var last error
wait := 100 * time.Millisecond
for attempt := 1; attempt <= max; attempt++ {
if err := ctx.Err(); err != nil { // 試行の前にキャンセルを確認
return errors.Join(last, err)
}
last = callAPI(attempt)
if last == nil {
return nil
}
if !retryable(last) {
return last
}
if attempt == max { // 最後の試行の後は待たない
break
}
select {
case <-ctx.Done():
return errors.Join(last, ctx.Err())
case <-time.After(wait):
}
wait = min(wait*2, 30*time.Second) // 待機は100msから倍々で上限30秒
}
return fmt.Errorf("giving up after %d attempts: %w", max, last)
}
callAPI は3回目で成功する代役で、この例のcontextは試行前の確認と待機の中断に使っています。実際の外部APIでは callAPI(ctx, attempt) のようにcontextを渡し、通信中のキャンセルも伝えます。上限2回で打ち切った場合と、50msのタイムアウトで中断した場合の出力です。
打ち切り: giving up after 2 attempts: callAPI attempt 2: rate limited
Is(RateLimited): true
中断 : callAPI attempt 1: rate limited
context deadline exceeded
Is(DeadlineExceeded): true
中断時に ctx.Err() だけを返すと、レート制限が原因だった事実が消えます。errors.Join で両方を残せば、「レート制限が続いた末にタイムアウトした」経緯が1つの値から読めます。待機を time.Sleep でなく select で書くのは、キャンセルを待機中にも受け取るためです。contextはGoのcontext入門|context.Background()とTODOの違い、WithCancel・WithTimeoutの実装【Go 1.27】、エラー文の読み分けはGoの「context deadline exceeded」「context canceled」の原因と対処|エラー文で発生箇所を見分ける【Go 1.27】で扱っています。
ラップを辿らない標準ヘルパーと型付きnil
fmt.Errorfでラップしたエラーとos.IsNotExistの判定
os.IsNotExist と os.IsPermission は errors.Is より古い関数です。ドキュメントには「It only supports errors returned by the os package.」とあり、*fs.PathError のようなosパッケージのエラーは中を見ますが、fmt.Errorf の %w で包んだチェーンは辿りません。
_, rawErr := os.Open("/tmp/missing.json")
err := fmt.Errorf("設定の読み込み: %w", rawErr)
fmt.Println(os.IsNotExist(err)) // false
fmt.Println(errors.Is(err, fs.ErrNotExist)) // true
fmt.Println(os.IsNotExist(rawErr)) // true(ラップ前なら真)
コンパイルエラーにもpanicにもならず、分岐だけが変わります。既存コードにラップを入れるときは、errors.Is(err, fs.ErrNotExist) / errors.Is(err, fs.ErrPermission) への置き換えを同時に行います。
型付きnilとerrorインターフェースのnil判定
インターフェースの値は「型と値」の組なので、値がnilでも型が入っていれば == nil はfalseです。
// 誤り:具体型のポインタを error として返している
func loadBad() error {
var e *ConfigError // nil のまま
return e
}
// 正しい:成功時は return nil と書き切る
func loadGood() error {
var e *ConfigError
if e != nil {
return e
}
return nil
}
loadBad() == nil : false
loadGood() == nil : true
loadBad() の中身 : <nil> (型 *main.ConfigError)
表示は <nil> なのに == nil がfalseという、追いにくい状態です。戻り値の型を具体型のポインタにせず error にし、成功時は変数を経由せず return nil と書きます。
if err != nilを減らす実装パターン
errWriterによる最初のエラーの保持と判定の集約
Rob PikeのErrors are values(2015年)の考え方で、同じ種類の操作を続ける処理では、最初のエラーを構造体に覚えさせ以降を何もしないようにします。
type errWriter struct {
w io.Writer
err error
}
func (ew *errWriter) printf(format string, args ...any) {
if ew.err != nil {
return
}
_, ew.err = fmt.Fprintf(ew.w, format, args...)
}
func writeReport(w io.Writer, rows []string) error {
ew := &errWriter{w: w}
ew.printf("# report\n")
for i, r := range rows {
ew.printf("%d,%s\n", i+1, r)
}
ew.printf("# total %d\n", len(rows))
return ew.err // 判定は最後の1回だけ
}
10バイト書いた後に「disk full」を返す Writer を渡すと、以降の printf は何もせず、最初のエラー「disk full」だけが返りました。bufio.Scanner も同じ設計で、ループを抜けた後に Err() を1回呼びます。呼び忘れると読み込み途中の失敗が正常終了に見えます。
cmp.Orによる一括判定と使ってはいけない場面
Go 1.22で追加された cmp.Or は、引数のうちゼロ値でない最初のものを返します。error のゼロ値はnilなので、複数の err を1回で判定できます。
x, err1 := strconv.Atoi(a)
y, err2 := strconv.Atoi(b)
if err := cmp.Or(err1, err2); err != nil {
return err
}
result: 3
<nil>
strconv.Atoi: parsing "x": invalid syntax
2回目は "x" と "y" の両方が不正ですが、返ったのは先頭の失敗だけです。もう1つの注意は、cmp.Or を呼ぶ前に全部の呼び出しが実行されることです。失敗したら後続の副作用(DB書き込みや外部API)を起こしてはいけない処理、前の結果を次の入力に使う処理では、呼び出しごとに if err != nil で抜けてください。全部の失敗を残したいなら errors.Join を使います。
check/handle・try・?の構文提案が見送られた経緯
if err != nil を構文で省く案は、Goチーム自身が3回出し、どれも取り下げています。経緯はRobert Griesemerの[ On | No ] syntactic support for error handling(2025年6月3日)にまとまっています。
| 時期 | 案 | 書き方 | 結果 |
|---|---|---|---|
| 2018年8月 | check / handle(ドラフト設計) | x := check f() |
複雑すぎるとして撤回 |
| 2019年6月 | try 組み込み関数(#32437) | x := try(f()) |
900件近いコメントの末に断念 |
| 2025年1月 | ? 演算子(#71203) | x := f() ? |
提案を閉じ議論へ移行 |
| 2025年6月 | Goチームの方針表明 | なし | 構文変更を当面追わない |
tryが退けられた理由としてブログは、深い式の中からでも関数を抜けてしまい制御の流れが見えなくなる点を挙げています。Ian Lance Taylorの ?演算子の提案(#71203)は、小規模な非公式のユーザー調査では大多数の参加者が意味を正しく推測できたものの、幅広い支持には至りませんでした。結論は「For the foreseeable future, the Go team will stop pursuing syntactic language changes for error handling.」で、主にエラー処理の構文を扱う提案は今後検討せずに閉じるとしています。if err != nil を置き換える構文を待つ理由はありません。
エラーを処理する層と伝播させる範囲の判断基準
はっきり言えるのは、発生した層ごとにログを出してはいけないことです。各層で出すと、1件の障害が呼び出しの深さの数だけログに並び、件数から深刻度を読めなくなります。ログはHTTPハンドラ・ワーカーのループ・mainといった最上位で1回だけ出します。
- その層で回復できるなら処理する。キャッシュに無ければ元データを取りに行く、のように代わりの経路がある場合です。
- 回復できないなら文脈を足して返す。ログもリトライもせず、
fmt.Errorfで1語足して%wで返します。 - 利用者への表示は最上位で組み立てる。内部のパスやSQLを返さないよう、境界で
errors.Is/errors.AsTypeで分類して文言に変えます。
この分担なら、エラーは「最上位の文脈 → 中間の文脈 → 根本原因」の順の1行になり、ログ検索で原因まで辿れます。Goで業務システムを新しく組む際の、エラー設計を含む設計・実装はフルスクラッチ開発で支援しています。他の言語の例外処理との比較はエラーハンドリングとは?例外処理のベストプラクティスとtry-catch-finallyの言語別の違いを参照してください。
よくある質問
Goにtry-catch(例外処理)が無いのはなぜですか?
エラーを通常の戻り値として扱い、失敗しうる箇所をコード上に見える形で残す設計だからです。例外では、どの関数が何を投げるかがシグネチャに出ず、捕捉漏れに気づけません。Goは (T, error) でそれを型に載せています。panic と recover は例外の代わりではなく、プログラムのバグを知らせる仕組みです。
errを_で捨ててもよいのはどんなときですか?
仕様でnilが保証されている場合と、失敗しても結果に影響しない理由を説明できる場合です。前者の例が bytes.Buffer の Write で、公式ドキュメントに「err is always nil」とあります。後者の例が読み取り専用で開いたファイルの Close です。書き込み用ファイルの Close のように遅れて失敗を返すものは捨ててはいけません。捨てた箇所は errcheck -blank で洗い出せます。
golangでnew errorはどう作りますか?
errors.Is で比べられる固定のエラーは、パッケージ変数として errors.New で作ります。呼び出しごとに内容が変わるエラーは fmt.Errorf で組み立て、下位のエラーを含めるなら %w でラップします。Go 1.26からは、書式指定の無い fmt.Errorf("x") のアロケーションも errors.New("x") と同程度になりました。
errors.Isとerrors.Asはどちらを使えばよいですか?
目的が違います。特定のエラーと一致するかは errors.Is、エラーが持つフィールド(HTTPステータスや再試行可否など)を読むなら errors.As です。Go 1.26以降なら型の取り出しは errors.AsType に置き換えられ、型の誤りがコンパイル時に見つかります。
Goのpanicはどんなときに使ってよいですか?
続けても意味がない、プログラム側のバグに限ります。起動時に必須の設定が無い、初期化に失敗した、といった場面です。regexp.MustCompile のように、固定値の入力で失敗したら即panicする Must 系関数も慣習です。ライブラリ内部でエラー伝播に使うpanicは、専用の型に限定して境界でrecoverし error に変えます。想定外のpanicまで一律に回収する必要はありません。