Go言語は、例外処理(エラーハンドリング)の仕組みが独特で、すべてif文で成り立っています。if err != nil という、あの独特な記述です。やたら繰り返し出てくることに、違和感・戸惑いを覚えたことがある人も多いのではないでしょうか。
- Pythonの
try / exceptと違ってこんなに面倒な書き方をするのか - 面倒さに見合うメリットはあるのか
本記事では、Goのエラー処理の考え方を解説します。
またあわせて、Google Colab上でGoのコードを動かしながら、
- エラーを
_で握りつぶすとどうなるか - エラーをそのまま返すだけだと何が困るか
- 同じ失敗を何度もログに残してしまう失敗
- 「想定内の失敗」と「そうでない失敗」をどう見分けるか
といった、よくある失敗例も紹介していきます。
結論を先に言うと、Goが if err != nil を書かせるのは、成功したか失敗したかを、その場で確認できるというメリットがあります。プログラムを作成する段階では手間でも、プログラムが使われ出して以降のログ調査・保守・チーム開発の場面では、地味に効いてきます。
学習を進めるために
なお、「Google ColabでGoのプログラムを動かすなんてできるの?」で引っかかっている読者は、まず下記記事で予習してください。
また、Go言語の基本文法をまだ見ていない人は、下記記事を先に見てください。本記事は、この記事の延長線上にあります。
すぐに使えるチートシートはこちら Google Colab版
すぐに実行して、試せるコードレシピはこちら。
おさらい: Goのエラーは「例外」ではなく「戻り値」
基本文法編でも登場した、割り算の例です。
%%writefile recap_div.go
package main
import (
"errors"
"fmt"
)
func div(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
func main() {
result, err := div(10, 0)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("result:", result)
}
実行結果は次のとおりです。
error: division by zero
div は、計算結果とエラーの2つを返します。
func div(a, b int) (int, error)
Pythonであれば、ゼロ除算は例外として投げられ、呼び出し元は try / except で受け止めます。
try:
result = a / b
except ZeroDivisionError as e:
print("error:", e)
Pythonの場合、a / b を書いた時点では、失敗する可能性を意識しなくても、コードとしては成立してしまいます。失敗への対応は、離れた場所にある except に任されています。
一方Goでは、div を呼んだその場で、err が nil かどうかを確認しなければ、次の行に進めません。成功と失敗、そのどちらだったのかを、呼び出した瞬間に突きつけられるイメージです。
ここから先は、この「その場で確認させられる」という性質が、具体的に何の役に立つのかを、失敗例と合わせて見ていきます。
失敗例: エラーを _ で握りつぶす
Goでは、エラーを受け取らずに捨てることもできます。
%%writefile ignore_error.go
package main
import (
"fmt"
"strconv"
)
func main() {
n, _ := strconv.Atoi("abc")
fmt.Println("nの値:", n)
fmt.Println("2倍すると:", n*2)
}
実行結果です。
nの値: 0
2倍すると: 0
"abc" は数値に変換できないので、本来はエラーになるはずです。しかし _ で受け取っているため、エラーが起きたという事実そのものを捨てています。
プログラムはエラーなく動き続けますが、n は変換に失敗した際のゼロ値である 0 のまま計算が進みます。実行結果を見ただけでは、これが「正しい0」なのか「失敗して0になった」のか、区別がつきません。
Pythonであれば int("abc") は例外を出してその場で止まってくれます。Goでこの危険なコードを書くには、_ とはっきり書く必要があります。逆に言えば、「エラーを無視する」という判断も、コード上に必ず跡が残るということです。
正解: errを確認してから使う
%%writefile check_error.go
package main
import (
"fmt"
"strconv"
)
func main() {
n, err := strconv.Atoi("abc")
if err != nil {
fmt.Println("変換に失敗しました:", err)
return
}
fmt.Println("2倍すると:", n*2)
}
実行結果です。
変換に失敗しました: strconv.Atoi: parsing "abc": invalid syntax
err を確認したことで、「間違った0」をそのまま使い続けることを防げました。if err != nil の1行は、まさにこの確認のために書かれています。
失敗例: エラーをそのまま返すだけだと、どこで失敗したか分からない
複数の関数をまたいでエラーが伝わっていく場面を考えます。
%%writefile bare_error.go
package main
import (
"errors"
"fmt"
)
var errConnRefused = errors.New("接続に失敗しました")
func queryOrders(userID int) error {
return errConnRefused
}
func createReport(userID int) error {
return queryOrders(userID)
}
func main() {
err := createReport(100)
fmt.Println(err)
}
実行結果です。
接続に失敗しました
createReport は queryOrders のエラーを、そのまま右から左へ返しているだけです。これでは、createReport の中のどの処理で失敗したのかが分かりません。
実際のプログラムでは、1つの関数がいくつもの処理を行い、その中のどれかひとつが失敗する、ということがよくあります。「接続に失敗しました」だけでは、障害調査のときに手がかりが足りません。
正解: fmt.Errorfで、通った場所を書き足す
fmt.Errorf と %w を使うと、元のエラーを保持したまま、新しいメッセージを前に足せます。
%%writefile wrapped_error.go
package main
import (
"errors"
"fmt"
)
var errConnRefused = errors.New("接続に失敗しました")
func queryOrders(userID int) error {
return fmt.Errorf("注文データの取得: %w", errConnRefused)
}
func createReport(userID int) error {
err := queryOrders(userID)
if err != nil {
return fmt.Errorf("user_id=%dのレポート作成: %w", userID, err)
}
return nil
}
func main() {
err := createReport(100)
fmt.Println(err)
}
実行結果です。
user_id=100のレポート作成: 注文データの取得: 接続に失敗しました
さきほどと原因は同じ「接続に失敗しました」ですが、今度は、
user_id=100のレポート作成 をしている途中
↓
注文データの取得 をしている途中
↓
接続に失敗しました
という、処理の通り道が1行のメッセージとして残ります。
障害調査の場面では、この1行があるだけで、どこから手をつければよいかが大きく変わります。queryOrders や createReport が、エラーを受け取るたびに if err != nil で立ち止まらされているからこそ、通った場所を書き足す機会が生まれている、という点がポイントです。
失敗例: 同じ失敗を、何度もログに出してしまう
もうひとつ、ありがちな失敗があります。通過するたびにログを出してしまうパターンです。
%%writefile duplicate_log.go
package main
import (
"errors"
"log"
)
var errConnRefused = errors.New("接続に失敗しました")
func queryOrders(userID int) error {
log.Println("queryOrders失敗:", errConnRefused)
return errConnRefused
}
func createReport(userID int) error {
err := queryOrders(userID)
if err != nil {
log.Println("createReport失敗:", err)
return err
}
return nil
}
func main() {
if err := createReport(100); err != nil {
log.Println("main失敗:", err)
}
}
実行結果の例です(先頭の日時部分は実行時刻によって変わります)。
2025/01/01 00:00:00 queryOrders失敗: 接続に失敗しました
2025/01/01 00:00:00 createReport失敗: 接続に失敗しました
2025/01/01 00:00:00 main失敗: 接続に失敗しました
起きている障害は1件なのに、ログには3行残ります。運用の現場でこれを見ると、「同じ障害が3回起きた」と誤解したり、件数が水増しされて調査に時間がかかったりします。
正解: ログは、いちばん外側で1回だけ
途中の関数では文脈だけを足して黙って返し、ログを出すのは呼び出しのいちばん外側だけにします。
%%writefile single_log.go
package main
import (
"errors"
"fmt"
"log"
)
var errConnRefused = errors.New("接続に失敗しました")
func queryOrders(userID int) error {
return fmt.Errorf("注文データの取得: %w", errConnRefused)
}
func createReport(userID int) error {
err := queryOrders(userID)
if err != nil {
return fmt.Errorf("user_id=%dのレポート作成: %w", userID, err)
}
return nil
}
func main() {
if err := createReport(100); err != nil {
log.Println("処理に失敗しました:", err)
}
}
実行結果の例です。
2025/01/01 00:00:00 処理に失敗しました: user_id=100のレポート作成: 注文データの取得: 接続に失敗しました
これで、1件の障害が1行のログとしてきれいに残ります。
「ここでログを出すべきか、それとも黙って文脈だけ足して次に渡すべきか」を、err を受け取るたびに判断させられるのは面倒です。ですが、この面倒さのおかげで、ログの重複という地味だけれど厄介な問題を避けやすくなっています。
「想定内の失敗」かどうかを見分ける
すべての失敗を同じ「エラー」として一括りにしてよいとは限りません。たとえば「在庫切れ」と「システム障害」では、対応がまったく違います。
こういうときに使うのが errors.Is です。
%%writefile errors_is.go
package main
import (
"errors"
"fmt"
)
var ErrOutOfStock = errors.New("在庫切れです")
func checkStock(item string, stock map[string]int) error {
if stock[item] <= 0 {
return ErrOutOfStock
}
return nil
}
func main() {
stock := map[string]int{"りんご": 0}
err := checkStock("りんご", stock)
if errors.Is(err, ErrOutOfStock) {
fmt.Println("在庫切れなので、入荷を待ってください")
} else if err != nil {
fmt.Println("システムエラーです。管理者に連絡してください:", err)
} else {
fmt.Println("注文できます")
}
}
実行結果です。
在庫切れなので、入荷を待ってください
errors.Is(err, ErrOutOfStock) を使うと、「これは、あらかじめ想定していた在庫切れなのか」をコード上で判定できます。在庫切れならユーザーに案内すればよいだけですが、それ以外のエラーであれば、システム側の問題として扱う必要があります。
この区別をコードに残しておくことで、「このエラーは無視してよいものか、それとも重大なものか」を、あとからコードを読む人が判断しやすくなります。
なぜGoはここまで面倒な書き方をさせるのか
Pythonは、正常な処理の流れを主役にして、失敗は「例外」という別枠の仕組みに任せます。だからこそ、正常系のコードは、失敗をあまり気にせず短く書けます。
Goは、1つの処理の結果を、最初から「成功」か「失敗」かのどちらかとして扱います。関数を呼ぶたびに、開発者はその場でどちらだったのかを確認させられます。これが if err != nil という、あの繰り返しの正体です。
面倒なようで、これらはどれも、Goが成功と失敗を対等に、その場で確認させるという設計を貫いているからこそ得られるものです。
- Goのエラーは「例外」ではなく、成功か失敗かを表す戻り値
-
_で握りつぶさず、その場でerrを確認する -
fmt.Errorf("...: %w", err)で文脈を足すと、ログ調査がしやすくなる - ログは境界(呼び出しのいちばん外側)で1回だけ出すと、障害の重複報告を防げる
-
errors.Isで「想定内の失敗」かどうかを見分けられる
さいごに
本記事では、Google Colab上でGoのエラー処理を、失敗例を交えながら学びました。
Pythonの try / except は、正常系を読みやすく書けるという大きな強みがあります。一方Goは、面倒を承知の上で、失敗と向き合うタイミングを開発者に先送りさせない思想なのだと考えられます。
if err != nil の繰り返しは、
- 失敗は"例外"(イレギュラー・特例)ではない
- 何か処理を実施すれば、成功・失敗、いずれの可能性もあり、どちらも想定しておくべき
という、Go言語の思想のあらわれだと考えられます。
弊社について
本記事を書いている 合同会社インクルーシブソリューションズ は、データ基盤構築・分析基盤設計・システム改善支援を中心に活動している小規模IT法人です。
主な領域は、
- データマート設計・データパイプライン構築
- SQL / Python を用いたデータ処理設計
- BI導入支援・分析基盤の整備
- 既存システムの運用改善・可視化支援
といった、「データを使える状態にする」ための活動です。
弊社の企業活動に興味がある方は、ぜひ公式サイトも覗いてみてください。