Goのエラーハンドリングとは、失敗しうる関数が最後の戻り値として error を返し、呼び出し側が if err != nil で受け取って「文脈を足して返す」「種類で分岐する」「記録する」のいずれかを行う仕組みです。 例外機構はなく、エラーはただの値なので、fmt.Errorf("...: %w", err) で包み、errors.Is/errors.As で連鎖をたどって判定します。

この記事では、error インターフェースの基本から、センチネルエラー・カスタムエラー型・ラップのどれを選ぶかの判断表とフロー、errors.Is/errors.As/errors.AsType(Go 1.26 で追加)/errors.Join/複数の %w/errors.Unwrap の実例、panic/recover の使いどころ、HTTP ハンドラでのステータスコード変換と log/slog での記録、型付き nil やシャドウイングなどよくある間違い、errors.Is を使ったテーブル駆動テストまでを、すべて Go 1.27.1 で実行した結果つきで解説します。

Goのエラーハンドリングの早見表

やりたいこと 書き方 補足
エラーを作る errors.New("msg") / fmt.Errorf("id=%d: msg", id) error は Error() string を持つだけのインターフェース
文脈を足して包む fmt.Errorf("loadItem(%s): %w", id, err) %w で元のエラーをたどれる。%v だと文字列になるだけ
種類で分岐する errors.Is(err, ErrNotFound) センチネルエラー(パッケージ変数)を連鎖の中から探す
値を取り出す var ve *ValidationError; errors.As(err, &ve) カスタムエラー型を連鎖の中から探す
値を取り出す(1.26〜) ve, ok := errors.AsType[*ValidationError](err) ジェネリック版。ポインタ変数の準備が不要で型安全
複数のエラーをまとめる errors.Join(errs...) / fmt.Errorf("%w; %w", e1, e2) Go 1.20〜。Is/As はすべての枝を探す
1段だけほどく errors.Unwrap(err) Join したエラーには nil を返す(Unwrap() []error を使う)
自作型を連鎖に参加させる func (e *E) Unwrap() error { return e.Err } これで Is/As が内側までたどる
復旧不能な状態で止める panic("config key must not be empty") 通常のエラーには使わない。起動時の設定不備など
panic を error に変換 defer func() { if r := recover(); r != nil { err = fmt.Errorf("recovered: %v", r) } }() recover は defer した関数の中でだけ効く
HTTP のステータスに変換 switch { case errors.Is(err, ErrNotFound): 404 ... } 変換は 1 か所にまとめ、内部の詳細は返さない
構造化ログに記録 slog.Error("request failed", "err", err) エラー値をそのまま渡す
テストで検証 if !errors.Is(err, tt.wantErr) { t.Errorf(...) } 文字列比較ではなく Is/AsType で比較

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Goエラーハンドリング|errors.Is/As・wrapの使い分け実例集」

errorインターフェースの基本と、%wと%vの違い

error は Error() string メソッドを1つ持つだけのインターフェースで、errors.New や fmt.Errorf が返す値もこれを満たす普通の値です。 大事なのは fmt.Errorf の書式で、%w は元のエラーを「包んで」後からたどれるようにし、%v は文字列として埋め込むだけという違いがあります。

package main

import (
    "errors"
    "fmt"
    "strconv"
)

// error は「Error() string を持つ」だけのインターフェース
// type error interface { Error() string }

func parsePort(s string) (int, error) {
    n, err := strconv.Atoi(s)
    if err != nil {
        // %w:元のエラーを「包む」。後から errors.Is / As / Unwrap でたどれる
        return 0, fmt.Errorf("parsePort(%q): %w", s, err)
    }
    if n < 1 || n > 65535 {
        return 0, errors.New("port out of range") // 新しいエラーを作る
    }
    return n, nil
}

func main() {
    _, err := parsePort("80a")
    fmt.Println("message :", err)
    fmt.Printf("type    : %T\n", err)
    fmt.Println("unwrap  :", errors.Unwrap(err)) // 包まれた元のエラー
    fmt.Println("is Err? :", errors.Is(err, strconv.ErrSyntax))

    // %v で作ると「文字列として埋め込む」だけで、元のエラーはたどれない
    base := errors.New("base error")
    wrapped := fmt.Errorf("with %%w: %w", base)
    formatted := fmt.Errorf("with %%v: %v", base)
    fmt.Println(wrapped, "->", errors.Unwrap(wrapped) != nil, errors.Is(wrapped, base))
    fmt.Println(formatted, "->", errors.Unwrap(formatted) != nil, errors.Is(formatted, base))

    _, err = parsePort("70000")
    fmt.Println("message :", err)
    fmt.Println("unwrap  :", errors.Unwrap(err)) // 包んでいないので nil
}
$ go run .
message : parsePort("80a"): strconv.Atoi: parsing "80a": invalid syntax
type    : *fmt.wrapError
unwrap  : strconv.Atoi: parsing "80a": invalid syntax
is Err? : true
with %w: base error -> true true
with %v: base error -> false false
message : port out of range
unwrap  : <nil>
書式 表示される文字列 errors.Unwrap errors.Is/As で元をたどれる
fmt.Errorf("...: %w", err) 同じ 元のエラー たどれる
fmt.Errorf("...: %v", err) 同じ nil たどれない
errors.New("...") 固定文字列 nil (包んでいない)

迷ったら %w で包みます。 表示される文字列は同じで、判定の手段が増えるだけだからです。あえて %v にするのは「内部の実装エラーを外に見せたくない(境界で意図的に切る)」場面に限られます。

センチネルエラー・カスタムエラー型・ラップの使い分け

エラーを返す側の設計は「呼び出し側が何をしたいか」で決まります。 種類だけ判定して分岐したいならセンチネルエラー、フィールドの値(どの項目か・いくつ足りないか)を使いたいならカスタムエラー型、判定は不要で文脈を足すだけならラップです。

方法 書き方 呼び出し側の判定 向いている場面 注意点
センチネルエラー var ErrNotFound = errors.New("item: not found") errors.Is(err, ErrNotFound) 「見つからない」「重複」など種類で分岐したい 値を持てない。公開すると API の一部になる
カスタムエラー型 type ValidationError struct{ Field, Reason string } + Error() errors.As / errors.AsType どの項目・どの値で失敗したかを使いたい ポインタで返すときは「型付き nil」に注意
ラップ fmt.Errorf("loadItem(%s): %w", id, err) そのまま上に返す 各層で文脈(関数名・ID)を足す %v にすると連鎖が切れる
まとめる errors.Join(errs...) errors.Is/As(全枝を探す) 検証を最後まで実行して全部返したい errors.Unwrap は nil を返す
flowchart TD
    A[エラーを返したい] --> B{呼び出し側は<br>何をしたい?}
    B -->|種類だけで分岐したい| C[センチネルエラー<br>var ErrX = errors.New]
    B -->|フィールドの値を使いたい| D[カスタムエラー型<br>type XError struct + Error]
    B -->|判定は不要・文脈を足すだけ| E[fmt.Errorf の %w でラップ]
    B -->|複数の失敗を全部返したい| F[errors.Join]
    C --> G[判定は errors.Is]
    D --> H[取り出しは errors.As / AsType]
    E --> I[上位層で Is / As がそのまま使える]
    F --> G
    F --> H

3つは排他ではなく組み合わせます。 下の層がセンチネルやカスタム型を返し、中間の層は %w で文脈を足して返し、いちばん上の層で errors.Is/As で分岐する、というのが典型的な形です。

errors.Is:センチネルエラーを連鎖の中から判定する

errors.Is(err, target) は、err を Unwrap でたどりながら target と等しいものがあるかを調べます。 何段包まれていても判定できるので、== の代わりに常に errors.Is を使います。

package main

import (
    "errors"
    "fmt"
)

// センチネルエラー:パッケージ変数として公開する「決まったエラー値」
var (
    ErrNotFound = errors.New("item: not found")
    ErrConflict = errors.New("item: already exists")
)

type Item struct{ ID, Name string }

// 層1:データ取得
func findItem(id string) (*Item, error) {
    if id != "A1" {
        return nil, ErrNotFound
    }
    return &Item{ID: id, Name: "ノート"}, nil
}

// 層2:業務処理。文脈を足して包み直す
func loadItem(id string) (*Item, error) {
    it, err := findItem(id)
    if err != nil {
        return nil, fmt.Errorf("loadItem(%s): %w", id, err)
    }
    return it, nil
}

// 層3:さらに包む
func handle(id string) error {
    if _, err := loadItem(id); err != nil {
        return fmt.Errorf("handle: %w", err)
    }
    return nil
}

func main() {
    err := handle("B2")
    fmt.Println("message        :", err)
    fmt.Println("err == ErrNotFound:", err == ErrNotFound)          // 包まれているので false
    fmt.Println("errors.Is       :", errors.Is(err, ErrNotFound))  // 連鎖をたどって true
    fmt.Println("errors.Is other :", errors.Is(err, ErrConflict))

    // 連鎖を1段ずつたどる
    for e := err; e != nil; e = errors.Unwrap(e) {
        fmt.Printf("  %T: %v\n", e, e)
    }
}
$ go run .
message        : handle: loadItem(B2): item: not found
err == ErrNotFound: false
errors.Is       : true
errors.Is other : false
  *fmt.wrapError: handle: loadItem(B2): item: not found
  *fmt.wrapError: loadItem(B2): item: not found
  *errors.errorString: item: not found

標準ライブラリにも os.ErrNotExist、io.EOF、context.Canceled、context.DeadlineExceeded、sql.ErrNoRows などのセンチネルがあり、同じように errors.Is で判定します。

errors.As と errors.AsType:カスタムエラー型の値を取り出す

errors.As(err, &target) は、連鎖の中から「target の型に代入できるエラー」を探して target に入れます。 Go 1.26 で追加された errors.AsType[T](err) はそのジェネリック版で、ポインタ変数をあらかじめ宣言する必要がなく、型の間違いがコンパイル時に分かります。

package main

import (
    "errors"
    "fmt"
)

// カスタムエラー型:呼び出し側が「値」を取り出したいときに使う
type ValidationError struct {
    Field  string
    Reason string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed: %s %s", e.Field, e.Reason)
}

// Unwrap を実装すると、さらに内側のエラーを持てる
type QueryError struct {
    Query string
    Err   error
}

func (e *QueryError) Error() string { return "query " + e.Query + ": " + e.Err.Error() }
func (e *QueryError) Unwrap() error { return e.Err }

var ErrTimeout = errors.New("timeout")

func validate(qty int) error {
    if qty <= 0 {
        return &ValidationError{Field: "qty", Reason: "must be positive"}
    }
    return nil
}

func save() error {
    return fmt.Errorf("save: %w", &QueryError{Query: "INSERT items", Err: ErrTimeout})
}

func main() {
    err := fmt.Errorf("register: %w", validate(0))
    fmt.Println(err)

    // errors.As:連鎖の中から「その型」の値を取り出す(Go 1.13〜)
    var ve *ValidationError
    if errors.As(err, &ve) {
        fmt.Println("As     : field =", ve.Field, "/ reason =", ve.Reason)
    }

    // errors.AsType:ジェネリック版。ポインタ変数の準備が不要(Go 1.26〜)
    if ve, ok := errors.AsType[*ValidationError](err); ok {
        fmt.Println("AsType : field =", ve.Field)
    }

    // Unwrap を持つカスタム型は、Is / As の両方が中までたどる
    err2 := save()
    fmt.Println(err2)
    fmt.Println("Is ErrTimeout :", errors.Is(err2, ErrTimeout))
    if qe, ok := errors.AsType[*QueryError](err2); ok {
        fmt.Println("AsType Query  :", qe.Query)
    }
    _, ok := errors.AsType[*ValidationError](err2)
    fmt.Println("AsType (別型) :", ok)
}
$ go run .
register: validation failed: qty must be positive
As     : field = qty / reason = must be positive
AsType : field = qty
save: query INSERT items: timeout
Is ErrTimeout : true
AsType Query  : INSERT items
AsType (別型) : false
観点 errors.Is errors.As errors.AsType(1.26〜)
何を探すか 「同じ値」のエラー 「代入できる型」のエラー 同左(型パラメータで指定)
対象 センチネルエラー カスタムエラー型 カスタムエラー型
使い方 errors.Is(err, ErrX) var e *XErr; errors.As(err, &e) e, ok := errors.AsType[*XErr](err)
型の誤りに気づく時点 ― 実行時(panic)/go vet コンパイル時
取り出した値 使わない(真偽だけ) e に入る 戻り値で受け取る

判断の目安は「if で分岐するだけなら Is、フィールドを読むなら As/AsType」です。 カスタム型を *ValidationError のようにポインタで返しているなら、As の第2引数は **ValidationError(&ve)、AsType の型引数は *ValidationError になります。

errors.Join と複数の %w:複数のエラーをまとめる

入力検証のように「最後まで実行して見つかった問題を全部返したい」ときは、errors.Join(Go 1.20〜)で1つの error にまとめます。 errors.Is/As はまとめられた全部の枝を探すので、呼び出し側の書き方は変わりません。

package main

import (
    "errors"
    "fmt"
)

var (
    ErrNameEmpty = errors.New("name is empty")
    ErrQtyRange  = errors.New("qty out of range")
)

type Order struct {
    Name string
    Qty  int
}

// すべての検証を実行し、見つかった問題をまとめて返す
func validate(o Order) error {
    var errs []error
    if o.Name == "" {
        errs = append(errs, ErrNameEmpty)
    }
    if o.Qty < 1 || o.Qty > 100 {
        errs = append(errs, fmt.Errorf("qty=%d: %w", o.Qty, ErrQtyRange))
    }
    return errors.Join(errs...) // errs が空なら nil を返す
}

func main() {
    err := validate(Order{Name: "", Qty: 0})
    fmt.Printf("--- Join (%T) ---\n%v\n", err, err) // 改行区切りで表示される
    fmt.Println("Is ErrNameEmpty:", errors.Is(err, ErrNameEmpty))
    fmt.Println("Is ErrQtyRange :", errors.Is(err, ErrQtyRange))
    fmt.Println("errors.Unwrap  :", errors.Unwrap(err)) // 複数を包むエラーには nil を返す

    // Unwrap() []error で個々のエラーを取り出せる
    if u, ok := err.(interface{ Unwrap() []error }); ok {
        for i, e := range u.Unwrap() {
            fmt.Printf("  [%d] %v\n", i, e)
        }
    }

    // fmt.Errorf に %w を複数書いても同じように扱える(Go 1.20〜)
    err2 := fmt.Errorf("order check: %w; %w", ErrNameEmpty, ErrQtyRange)
    fmt.Printf("--- multi %%w (%T) ---\n%v\n", err2, err2)
    fmt.Println("Is both:", errors.Is(err2, ErrNameEmpty), errors.Is(err2, ErrQtyRange))

    fmt.Println("--- no error ---")
    fmt.Println(validate(Order{Name: "pen", Qty: 3}) == nil)
}
$ go run .
--- Join (*errors.joinError) ---
name is empty
qty=0: qty out of range
Is ErrNameEmpty: true
Is ErrQtyRange : true
errors.Unwrap  : <nil>
  [0] name is empty
  [1] qty=0: qty out of range
--- multi %w (*fmt.wrapErrors) ---
order check: name is empty; qty out of range
Is both: true true
--- no error ---
true
使い分け errors.Join(e1, e2, ...) fmt.Errorf("%w; %w", e1, e2)
表示 改行区切り 書式どおり(1行にできる)
個数 可変(スライスを展開) 書式に書いた数だけ
nil の扱い nil は無視。全部 nil なら nil を返す nil を渡すと %!w(<nil>) と表示される
向く場面 検証結果の集約、複数リソースの後始末 2つのエラーを文脈つきで1行にしたい

errors.Unwrap は「1段だけほどく」関数で、Unwrap() error を持つエラーにしか効きません。 Join や複数 %w のエラーは Unwrap() []error を持つため nil が返ります。個々に触りたいときは上のように型アサーションで取り出しますが、普段は Is/As に任せれば十分です。

panic と recover の使いどころ

panic は「プログラムをこれ以上続けられない」ときの仕組みで、通常の失敗(入力の誤り・見つからない・タイムアウト)には使いません。 使うのは起動時の設定不備や、到達しないはずのコードに到達したときなどです。recover は defer した関数の中でだけ効き、panic を error に変換して返す形が定番です。

package main

import (
    "fmt"
    "runtime"
)

// recover は defer した関数の中でだけ効く。panic を error に変換して返す定番形
func safeDiv(a, b int) (result int, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("recovered: %v", r)
        }
    }()
    return a / b, nil // b == 0 なら実行時 panic
}

// 設定ミスなど「起動時に止めるべき」場面では panic を使ってよい
func mustLoad(key string) string {
    if key == "" {
        panic("config key must not be empty")
    }
    return "value"
}

func main() {
    v, err := safeDiv(10, 2)
    fmt.Println(v, err)
    v, err = safeDiv(1, 0)
    fmt.Println(v, err)

    // recover した値の型を調べる:ランタイム起因なら runtime.Error を満たす
    func() {
        defer func() {
            if r := recover(); r != nil {
                _, isRuntime := r.(runtime.Error)
                fmt.Printf("recovered %T (runtime.Error=%v): %v\n", r, isRuntime, r)
            }
        }()
        var m map[string]int
        m["k"] = 1
    }()

    fmt.Println(mustLoad("app.name"))
    fmt.Println("--- recover しない panic はプログラムを終了させる ---")
    mustLoad("")
    fmt.Println("ここには到達しない")
}
$ go run .
5 <nil>
0 recovered: runtime error: integer divide by zero
recovered runtime.plainError (runtime.Error=true): assignment to entry in nil map
value
--- recover しない panic はプログラムを終了させる ---
panic: config key must not be empty

goroutine 1 [running]:
main.mustLoad(...)
    /path/to/main.go:21
main.main()
    /path/to/main.go:46 +0x1a7
exit status 2
場面 使うもの 理由
入力の誤り・見つからない・タイムアウト error を返す 呼び出し側が判断して続行できる
起動時の設定不備(必須の環境変数がない等) panic または log.Fatal 続行しても意味がない。regexp.MustCompile の Must 系も同じ発想
「ここには来ないはず」の分岐 panic("unreachable: ...") バグをすぐ表面化させる
HTTP サーバーのハンドラ内で起きた panic ミドルウェアで recover して 500 を返す 1リクエストの問題でプロセス全体を止めない(net/http は既定でも recover して接続を閉じる)
goroutine 内の panic その goroutine の中で recover 別の goroutine の defer では拾えない

HTTPハンドラでエラーをステータスコードに変換し、slog で記録する

業務ロジックは HTTP を知らずに error を返し、ハンドラ側の1か所で「エラー → ステータスコード」に変換するのが整理しやすい形です。 error を返すハンドラ型を定義すると、変換とログ出力を共通化できます。ログは log/slog にエラー値をそのまま渡します。

package main

import (
    "encoding/json"
    "errors"
    "fmt"
    "log/slog"
    "net/http"
    "net/http/httptest"
    "os"
    "strconv"
)

var ErrNotFound = errors.New("item not found")

type ValidationError struct{ Field, Reason string }

func (e *ValidationError) Error() string { return e.Field + " " + e.Reason }

// 業務ロジック:HTTP のことは知らない(エラーを返すだけ)
func getItem(id string) (map[string]any, error) {
    n, err := strconv.Atoi(id)
    if err != nil {
        return nil, &ValidationError{Field: "id", Reason: "must be a number"}
    }
    if n != 1 {
        return nil, fmt.Errorf("getItem(%d): %w", n, ErrNotFound)
    }
    return map[string]any{"id": n, "name": "ノート"}, nil
}

// エラー → HTTP ステータスの変換は 1 か所にまとめる
func statusOf(err error) (int, string) {
    var ve *ValidationError
    switch {
    case errors.Is(err, ErrNotFound):
        return http.StatusNotFound, "not found"
    case errors.As(err, &ve):
        return http.StatusBadRequest, ve.Error()
    default:
        return http.StatusInternalServerError, "internal error" // 内部の詳細は返さない
    }
}

// error を返すハンドラ型を定義し、変換を共通化する
type appHandler func(w http.ResponseWriter, r *http.Request) error

func (h appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if err := h(w, r); err != nil {
        status, msg := statusOf(err)
        // slog には元のエラーをそのまま渡す("err" キーで構造化される)
        slog.Error("request failed", "method", r.Method, "path", r.URL.Path, "status", status, "err", err)
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(status)
        json.NewEncoder(w).Encode(map[string]string{"error": msg})
    }
}

func itemHandler(w http.ResponseWriter, r *http.Request) error {
    item, err := getItem(r.PathValue("id"))
    if err != nil {
        return err
    }
    return json.NewEncoder(w).Encode(item)
}

func main() {
    slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
            if a.Key == slog.TimeKey { // 出力を安定させるため時刻は省く
                return slog.Attr{}
            }
            return a
        },
    })))

    mux := http.NewServeMux()
    mux.Handle("GET /items/{id}", appHandler(itemHandler))

    // httptest でサーバーを立てずに呼び出す
    for _, path := range []string{"/items/1", "/items/2", "/items/abc"} {
        rec := httptest.NewRecorder()
        mux.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
        fmt.Printf("GET %-11s -> %d %s", path, rec.Code, rec.Body.String())
    }
}
$ go run .
GET /items/1    -> 200 {"id":1,"name":"ノート"}
{"level":"ERROR","msg":"request failed","method":"GET","path":"/items/2","status":404,"err":"getItem(2): item not found"}
GET /items/2    -> 404 {"error":"not found"}
{"level":"ERROR","msg":"request failed","method":"GET","path":"/items/abc","status":400,"err":"id must be a number"}
GET /items/abc  -> 400 {"error":"id must be a number"}
エラーの種類 ステータス クライアントに返す内容 ログ
入力の誤り(ValidationError) 400 どの項目がどう誤りか WARN か ERROR(頻度に応じて)
見つからない(ErrNotFound) 404 「not found」 通常は INFO 程度で十分
権限がない 403 「forbidden」 詳細はログにだけ
想定外(上記以外) 500 「internal error」(詳細は返さない) ERROR で元のエラーをそのまま記録

ログには包んだままのエラー(getItem(2): item not found)を残し、レスポンスには変換後の短い文言だけを返す、という分離がポイントです。

よくある間違いと実行結果(直す前と後)

1. err のシャドウイングで、エラーが外に出ない

if や内側のブロックで := を使うと「新しい err」が作られ、外側の err は nil のままです。コンパイルは通り、go vet の標準設定でも検出されないので、実行結果で確認します。

package main

import (
    "errors"
    "fmt"
)

func step1() (int, error) { return 0, nil }
func step2() (int, error) { return 0, errors.New("step2 failed") }

func run() error {
    v, err := step1()
    if v == 0 {
        v, err := step2() // := で「新しい err」が作られる(外の err には入らない)
        fmt.Println("inner:", v, err)
    }
    return err // 外の err は nil のまま
}

func main() {
    fmt.Println("run() =", run())
}
$ go run .
inner: 0 step2 failed
run() = <nil>

→ 内側では v, err = step2() と = で代入する(v も外で宣言しておく)か、if v2, err := step2(); err != nil { return err } のようにその場で返す形にします。

2. 型付き nil:*MyErr を返すと err != nil が true になる

package main

import "fmt"

type MyErr struct{ Msg string }

func (e *MyErr) Error() string { return e.Msg }

// 戻り値の型を *MyErr にすると、nil を返しても error としては nil にならない
func doBad() error {
    var e *MyErr // nil ポインタ
    return e     // (*MyErr)(nil) が error に入る → 「型あり nil」
}

func doGood() error {
    var e *MyErr
    if e == nil {
        return nil // error 型の nil を返す
    }
    return e
}

func main() {
    err := doBad()
    fmt.Printf("doBad : err != nil -> %v (%T)\n", err != nil, err)
    err = doGood()
    fmt.Printf("doGood: err != nil -> %v (%T)\n", err != nil, err)
}
$ go run .
doBad : err != nil -> true (*main.MyErr)
doGood: err != nil -> false (<nil>)

→ インターフェース値は「型」と「値」の組で、型が入っていれば値が nil でも nil とは等しくなりません。 エラーを返す関数の戻り値の型は常に error にし、成功時は明示的に return nil と書きます。

3. エラーを握りつぶすと、ゼロ値のまま処理が進む

package main

import (
    "fmt"
    "strconv"
)

func main() {
    // 直す前:エラーを捨てると、ゼロ値のまま処理が進む
    qty, _ := strconv.Atoi("10") // 全角数字は変換できない
    fmt.Println("直す前: qty =", qty, "→ 在庫を", qty, "個引き当てて続行")

    // 直した後:エラーを返す/文脈を足して呼び出し元に任せる
    if err := reserve("10"); err != nil {
        fmt.Println("直した後:", err)
    }
}

func reserve(s string) error {
    qty, err := strconv.Atoi(s)
    if err != nil {
        return fmt.Errorf("reserve: invalid qty %q: %w", s, err)
    }
    fmt.Println("引き当て:", qty)
    return nil
}
$ go run .
直す前: qty = 0 → 在庫を 0 個引き当てて続行
直した後: reserve: invalid qty "10": strconv.Atoi: parsing "10": invalid syntax

→ _ で捨てるのは「失敗しても本当に問題ない」と説明できるときだけにし、それ以外は返すか記録します。errcheck(golangci-lint に含まれる)を CI に入れると、捨てている箇所を機械的に見つけられます。

4. 文字列比較は、文脈を足した時点で一致しなくなる

package main

import (
    "errors"
    "fmt"
    "os"
)

func main() {
    _, err := os.Open("/no/such/file")
    err = fmt.Errorf("load config: %w", err) // 文脈を足すと文字列が変わる

    fmt.Println("message:", err)
    fmt.Println("string compare:", err.Error() == "open /no/such/file: no such file or directory")
    fmt.Println("errors.Is     :", errors.Is(err, os.ErrNotExist))
}
$ go run .
message: load config: open /no/such/file: no such file or directory
string compare: false
errors.Is     : true

→ err.Error() == "..." や strings.Contains(err.Error(), "...") は、包んだ瞬間・文言を直した瞬間に壊れます。判定は errors.Is/As で、文字列は人が読むためのものと割り切ります。

5. %w に nil を渡す・Sprintf に %w を書く・errors.As にポインタ以外を渡す

var err error // nil
e := fmt.Errorf("load: %w", err) // nil を %w で包むと…
fmt.Println(e, "/ nil?", e == nil)

s := fmt.Sprintf("failed: %w", errors.New("x")) // Sprintf に %w は使えない
fmt.Println(s)
$ go vet .
./main.go:13:28: fmt.Sprintf does not support error-wrapping directive %w

$ go run .
load: %!w(<nil>) / nil? false
failed: %!w(*errors.errorString=&{x})
var target MyErr
fmt.Println(errors.As(err, target)) // ポインタを渡していない
$ go vet .
./main.go:15:14: second argument to errors.As must be a non-nil pointer to either a type that implements error, or to any interface type

$ go run .
panic: errors: target must be a non-nil pointer
間違い 起きること 直し方
fmt.Errorf("...: %w", nil) %!w(<nil>) を含む非 nil のエラーになる if err != nil の中でだけ包む
fmt.Sprintf("%w", err) %!w(...) と表示。go vet が検出 %v にする(包むのは Errorf だけ)
errors.As(err, target)(非ポインタ) 実行時 panic。go vet が検出 errors.As(err, &target) か errors.AsType[T](err)
errors.As(err, &v) で v が値型なのにポインタ型を返している 一致せず false 返している型(*MyErr)に合わせる

テストでのエラー検証(errors.Is を使ったテーブル駆動テスト)

テストでも文字列比較ではなく errors.Is/errors.AsType で検証すると、文言を直しても壊れないテストになります。 期待するセンチネルを表の列に持たせるのが定番です。

// stock.go
package stock

import (
    "errors"
    "fmt"
)

var ErrNotFound = errors.New("stock: item not found")

type InsufficientError struct {
    Item      string
    Requested int
    Available int
}

func (e *InsufficientError) Error() string {
    return fmt.Sprintf("stock: %s requested %d but only %d available", e.Item, e.Requested, e.Available)
}

type Store struct{ items map[string]int }

func New(items map[string]int) *Store { return &Store{items: items} }

// Take は在庫を n 個引き当てます。
func (s *Store) Take(item string, n int) error {
    avail, ok := s.items[item]
    if !ok {
        return fmt.Errorf("take %q: %w", item, ErrNotFound)
    }
    if avail < n {
        return &InsufficientError{Item: item, Requested: n, Available: avail}
    }
    s.items[item] = avail - n
    return nil
}
// stock_test.go
package stock

import (
    "errors"
    "testing"
)

func TestTake(t *testing.T) {
    tests := []struct {
        name      string
        item      string
        n         int
        wantIs    error // センチネルを期待するとき(errors.Is で判定)
        wantAvail int   // *InsufficientError を期待するとき(0 なら期待しない)
    }{
        {name: "成功", item: "pen", n: 2},
        {name: "存在しない品目", item: "cup", n: 1, wantIs: ErrNotFound},
        {name: "在庫不足", item: "pen", n: 99, wantAvail: 5},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            s := New(map[string]int{"pen": 5})
            err := s.Take(tt.item, tt.n)

            switch {
            case tt.wantIs != nil: // センチネル:包まれていても errors.Is でたどれる
                if !errors.Is(err, tt.wantIs) {
                    t.Errorf("Take() error = %v, want %v", err, tt.wantIs)
                }
            case tt.wantAvail != 0: // カスタム型:errors.AsType で値まで検証
                ie, ok := errors.AsType[*InsufficientError](err)
                if !ok {
                    t.Fatalf("Take() error = %v, want *InsufficientError", err)
                }
                if ie.Available != tt.wantAvail {
                    t.Errorf("Available = %d, want %d", ie.Available, tt.wantAvail)
                }
            default:
                if err != nil {
                    t.Errorf("Take() unexpected error: %v", err)
                }
            }
        })
    }
}
$ go test -v ./stock
=== RUN   TestTake
=== RUN   TestTake/成功
=== RUN   TestTake/存在しない品目
=== RUN   TestTake/在庫不足
--- PASS: TestTake (0.00s)
    --- PASS: TestTake/成功 (0.00s)
    --- PASS: TestTake/存在しない品目 (0.00s)
    --- PASS: TestTake/在庫不足 (0.00s)
PASS
ok      example.com/errdemo/stock   0.002s

このテストは「%w を %v に書き間違えて連鎖が切れた」というよくある変更も検出します。Take の %w を %v に変えて実行すると次のように落ちます。

$ go test ./stock
--- FAIL: TestTake (0.00s)
    --- FAIL: TestTake/存在しない品目 (0.00s)
        stock_test.go:28: Take() error = take "cup": stock: item not found, want stock: item not found
FAIL
FAIL    example.com/errdemo/stock   0.003s

よくある質問

Q. すべてのエラーを %w で包むべきですか? A. 基本は包みます。文脈(関数名・ID)が付いて追跡しやすくなり、errors.Is/As も効くためです。例外は「パッケージの内部実装を外に見せたくない」境界で、そこでは %v で切るか、自分のパッケージのセンチネルに変換して返します。

Q. errors.As と errors.AsType のどちらを使えばよいですか? A. Go 1.26 以降なら errors.AsType が簡潔で、型の誤りがコンパイル時に分かります。1.25 以前もサポートするライブラリでは errors.As を使います。動作はどちらも同じで、連鎖を深さ優先でたどります。

Q. エラーメッセージの書き方に決まりはありますか? A. 慣例として「小文字で始め、句読点や改行で終えない」「パッケージ名: 内容 のように前置きを付ける」「包むときは 関数名(引数): %w のように文脈だけ足す」です。上位で包まれるたびに : で連結されるので、途中に「エラー:」のような語を入れないと読みやすくなります。

Q. ログはどの層で出すべきですか? A. 「処理を打ち切る層」(HTTP ハンドラや CLI の main)で1回だけ出します。各層で記録しつつ上にも返すと、同じエラーが何度も記録されます。途中の層は %w で文脈を足して返すだけにします。

まとめ

  • error はただの値。失敗しうる関数は最後の戻り値で返し、if err != nil で受け取る
  • 文脈を足すときは fmt.Errorf("...: %w", err)。%v は連鎖が切れる
  • 種類で分岐するならセンチネル+errors.Is、値を使うならカスタム型+errors.As/AsType(1.26〜)
  • 複数の失敗は errors.Join か複数の %w でまとめ、Is/As は全枝を探す。errors.Unwrap は nil を返す
  • panic は続行できない状態専用。recover は defer の中でだけ効き、error に変換して返す
  • HTTP では 1 か所でエラー→ステータスに変換し、slog には元のエラーをそのまま記録する
  • シャドウイング・型付き nil・握りつぶし・文字列比較は実行結果で確認し、テストは errors.Is/AsType で書く

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Goエラーハンドリング|errors.Is/As・wrapの使い分け実例集」

株式会社RJCでは、エラーを「値」として設計するGoの考え方を、コードレビューの中で仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?