Developer writing server code on a laptop

Go Error Handling Best Practices

August 20, 2026 · 14 min read · By Dagny Taggart

Key Takeaways:

  • Go treats errors as ordinary values returned from functions, not as thrown exceptions. There is no try/except and no exception hierarchy.
  • The idiomatic pattern is if err != nil { return err }, checked immediately after every call that can fail.
  • Sentinel errors are package-level variables like var ErrNotFound = errors.New("not found"), matched with errors.Is rather than ==, which breaks once the error is wrapped.
  • Wrap errors with fmt.Errorf("...: %w", err) to preserve the original error chain for later inspection.
  • Use errors.Is to match specific sentinels and errors.As to extract typed errors from wrapped chains.
  • Reserve panic for genuinely unrecoverable programmer errors; use recover sparingly, only at the top of a goroutine or defensive boundary.

Go does not have exceptions. That single sentence explains most of the confusion developers from Python, Java, or JavaScript feel the first time they write a Go program. There is no try/except block, no throw statement, and no class hierarchy of exception types to subclass. Instead, a function that can fail returns an error as an ordinary value alongside its result, and the caller checks that value explicitly. The language’s design philosophy, documented in the Effective Go guide, is that error handling should be plain, visible, and impossible to ignore by accident.

The result is code where the happy path and failure path sit side by side, line by line, rather than being separated into a distant catch block. This feels verbose at first, but it forces you to confront every failure mode at the exact point where it can occur. This article walks through the complete toolkit: the error interface, sentinel errors, wrapping with %w, inspection with errors.Is and errors.As, custom error types, defer for cleanup, and the narrow role of panic and recover.

Errors Are Values, Not Exceptions

Go error handling flow diagram showing the if err != nil pattern

At the center of Go’s error model is a tiny interface defined in the standard library. The error type is simply:

type error interface {
 Error() string
}

Any type with an Error() string method satisfies this interface. That is the entire contract. There is no stack trace attached by default, no severity level, and no inheritance. An error is just a value that can describe itself as a string, and you carry it around exactly as you would carry an int or string.

A function that can fail returns two values, conventionally the result first and the error second:

package main

import (
 "fmt"
 "os"
)

func main() {
 data, err := os.ReadFile("config.json")
 if err != nil {
 fmt.Println("failed to read config:", err)
 return
 }
 fmt.Println(string(data))
}

The if err != nil check is the single most recognizable line in the Go ecosystem. It appears immediately after every call that can fail, before the result is used. This pattern is deliberate: the error is checked at the source, in the same breath as the call that produced it, so a partially initialized or zero-valued result never leaks into downstream logic.

One common mistake is to ignore the error with the blank identifier, data, _ := os.ReadFile(...), or to check the result before checking the error. Both are discouraged. The result of a failed call is almost always meaningless, often the zero value of its type, so the error check must come first. The Go team’s guidance in Effective Go is explicit on this ordering.

The idiomatic rule is short: handle the error where you are, either by returning it up the stack or by genuinely recovering from it. If you do nothing, the failure is silently swallowed, which is worse than a crash because it produces wrong behavior with no signal.

Sentinel Errors and the errors.New Pattern

A sentinel error is a package-level variable that holds a specific, named error value. Callers compare the error they receive against this variable to determine what went wrong. The standard library is full of them: io.EOF, sql.ErrNoRows, os.ErrNotExist.

You create one with errors.New, which takes a string and returns a pointer to an errorString value:

package store

import "errors"

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

func Get(id string) (Record, error) {
 rec, ok := records[id]
 if !ok {
 return Record{}, ErrNotFound
 }
 return rec, nil
}

Because errors.New returns a pointer, two separate calls to errors.New("record not found") produce two distinct values that are not equal under ==. The convention of declaring a sentinel once, at package level, and returning that same value everywhere is what makes comparison reliable.

Callers then match against it directly:

rec, err := store.Get(id)
if err == store.ErrNotFound {
 // handle not found
}

The naming convention matters for readability. Sentinel errors are exported variables, so they start with Err: ErrNotFound, ErrTimeout, ErrInvalidArgument. The error message itself should be lowercase and should not end with punctuation, matching how the standard library phrases its own errors.

A subtlety worth knowing: comparing sentinel errors with == only works when the error is returned unwrapped. The moment you wrap it (which we will cover next), direct equality breaks and you need errors.Is. This is a common source of bugs for developers who wrap errors in one layer of the codebase but still compare with == in another.

Wrapping Errors with fmt.Errorf and %w

When a function receives an error from a lower layer and returns it upward, it often adds context: which operation failed, which file was being read, which user ID was involved. The idiomatic way to do this is fmt.Errorf with the %w verb, introduced in Go 1.13.

func LoadUser(id string) (User, error) {
 data, err := os.ReadFile("users/" + id + ".json")
 if err != nil {
 return User{}, fmt.Errorf("loading user %s: %w", id, err)
 }
 var u User
 if err := json.Unmarshal(data, &u); err != nil {
 return User{}, fmt.Errorf("parsing user %s: %w", id, err)
 }
 return u, nil
}

The %w verb wraps the original error so that it remains inspectable later, while the surrounding text adds human-readable context. The resulting message reads loading user 42: open users/42.json: no such file or directory, but crucially, the original os.ErrNotExist sentinel is still reachable through the chain.

There is an important distinction between %w and %v in this context. Using %v (or %s) formats the error’s message into a new string but discards the original value, breaking the chain. Using %w preserves it. The rule of thumb: use %w when you want callers to be able to match or extract the underlying error, and reserve %v for cases where the underlying error is genuinely irrelevant to callers.

A note on combining causes: before Go 1.20 a single fmt.Errorf call could wrap only one error, and a second %w was not treated as a wrap at all. Since Go 1.20 a call may contain multiple %w verbs, and every wrapped error stays reachable through the chain. When you are merging a dynamic list of errors rather than writing a fixed format string, errors.Join (also added in 1.20) is the clearer tool: it returns one error that keeps each cause individually matchable with errors.Is and errors.As.

Wrapping also lets you build a layered error chain as the error travels up through multiple packages, each adding its own context: the database layer notes the query, the service layer notes the operation, the handler notes the request. The final message tells the story, and the chain lets any layer or top-level handler classify the error precisely.

Inspecting Wrapped Errors with errors.Is and errors.As

Once errors are wrapped, you need tools to look inside the chain. Go provides two, and they serve different purposes.

errors.Is answers the question “is this error, or any error in its chain, equal to this sentinel?” It walks the chain by calling each error’s Unwrap() error method (if present) and compares at each step. This is the replacement for == when wrapping is in play:

_, err := LoadUser("42")
if errors.Is(err, os.ErrNotExist) {
 // handle missing file
}

errors.As answers a different question: “is there an error in this chain of a specific concrete type, and if so, can I extract it?” It takes a pointer to a target variable and assigns the first matching error to it:

var pathErr *os.PathError
if errors.As(err, &pathErr) {
 fmt.Println("failed path:", pathErr.Path)
}

Here the code recovers the original *os.PathError even though it was wrapped several times, and can then read its Path field. The two functions are complementary: errors.Is is for sentinel comparison, errors.As is for type assertion across the chain.

Both functions respect custom Is and As methods on error types. If your error type implements Is(target error) bool, then errors.Is will call it rather than relying on equality alone. This lets you build errors that match a broader category: for example, a PermissionError that reports true when asked whether it errors.Is(err, os.ErrPermission).

Function Question it answers Target Typical use
errors.Is(err, target) Is any error in the chain equal to this value? A sentinel error variable Matching io.EOF, sql.ErrNoRows
errors.As(err, &target) Is any error in the chain of this concrete type? A pointer to a typed variable Extracting *os.PathError, custom types
err == sentinel Is this unwrapped error exactly equal? A sentinel error variable Only safe when errors are never wrapped

The third row is a trap: direct == comparison breaks silently the moment someone wraps the error in a middle layer. The Go team’s guidance, and the consensus in the community, is to prefer errors.Is whenever the error has passed through code you do not fully control.

Custom Error Types That Implement the error Interface

Sentinel errors tell you what category of failure occurred, but they carry no data. When callers need structured information, such as a status code, a retryable flag, or a specific field, you define a custom type that implements Error() string.

package store

import "fmt"

type NotFoundError struct {
 Resource string
 ID string
}

func (e *NotFoundError) Error() string {
 return fmt.Sprintf("%s %q not found", e.Resource, e.ID)
}

This type satisfies the error interface because it has an Error() string method. It also carries two fields the caller can inspect. A function returns it as a regular error value, and the caller recovers the concrete type with errors.As:

_, err := store.Get(id)
var nf *store.NotFoundError
if errors.As(err, &nf) {
 fmt.Println("missing", nf.Resource, "with ID", nf.ID)
}

Notice the method has a pointer receiver, func (e *NotFoundError) Error() string. This is deliberate. With a pointer receiver, only *NotFoundError values implement error, which matters when errors.As is matching against a *NotFoundError target. Mixing value and pointer receivers here is a frequent source of subtle bugs where errors.As fails to match.

A custom type can also implement Unwrap() error to participate in the wrapping chain, and Is(target error) bool to customize sentinel matching. A common pattern is a WrapError that stores both a message and the underlying cause:

type WrapError struct {
 Msg string
 Err error
}

func (e *WrapError) Error() string { return e.Msg }
func (e *WrapError) Unwrap() error { return e.Err }

With Unwrap implemented, errors.Is and errors.As can see through WrapError to the cause it holds, exactly as they would through an error created with fmt.Errorf and %w. The trade-off is that a hand-rolled wrapper is more code to maintain than a one-line fmt.Errorf, so reach for a custom type only when you need structured fields, not merely context.

Using defer for Cleanup and Error Handling

defer schedules a function call to run when the surrounding function returns, in last-in-first-out order. Its primary job in error handling is cleanup: closing files, releasing locks, and rolling back transactions, regardless of which return path is taken.

func process(path string) (err error) {
 f, err := os.Open(path)
 if err != nil {
 return err
 }
 defer f.Close()
 return nil
}

The defer f.Close() guarantees the file is closed whether process returns normally or via an early return err on a failed read. This is the standard pattern for resource management in Go, and it removes the need for manual cleanup sprinkled through every error branch that languages without defer require.

A more advanced use is capturing and augmenting the return error. By naming the return value err (as in the signature above), a deferred function can modify it before the function actually returns:

func process(path string) (err error) {
 f, err := os.Open(path)
 if err != nil {
 return err
 }
 defer func() {
 if cerr := f.Close(); cerr != nil && err == nil {
 err = cerr
 }
 }()
 return nil
}

Here the deferred closure checks whether closing failed and, if no earlier error is already set, assigns the close error to the named return value. This preserves a close failure that would otherwise be silently discarded, because a bare defer f.Close() has no way to surface its error to the caller. This idiom appears throughout the standard library and in production code that must not lose a close or flush error.

One caveat: defer arguments are evaluated immediately, when the defer statement runs, not when the deferred function executes. If you write defer log(err), the value of err is captured at that moment. To observe the final value, wrap it in a closure, as the Close example above does.

Panic and Recover: Reserved for Unrecoverable States

panic is Go’s mechanism for stopping normal execution and unwinding the stack. It is not a general-purpose error signal. The language’s own documentation and the wider community agree that panic should be reserved for programmer errors and truly unrecoverable states: an out-of-bounds index, a nil pointer dereference, an invariant that should be impossible.

You do not use panic to report that a file was not found or a network call timed out. Those are expected, recoverable conditions, and they belong in the ordinary error return path. Using panic for them forces every caller to know about and defend against panics, which defeats the explicit, value-based model the rest of the language encourages.

recover is the counterpart: it stops a panic and returns the value passed to panic. It only works when called directly from a deferred function, because by the time the deferred function runs during unwinding, the panic is active and recover can intercept it:

func safeRun(fn func()) (err error) {
 defer func() {
 if r := recover(); r != nil {
 err = fmt.Errorf("recovered from panic: %v", r)
 }
 }()
 fn()
 return nil
}

This pattern is occasionally justified at the boundary of a goroutine, since a panic in a goroutine crashes the entire program if unhandled, or in a top-level request handler that must not let one bad request take down the server. Even there, it is a defensive last resort, not a control-flow tool.

The discipline to keep in mind: recover the panic, log it thoroughly, and return a proper error to the caller. Do not silently swallow it, and do not use panic/recover as a substitute for returning errors. Code that throws and catches panics as a normal flow pattern is widely considered unidiomatic and hard to reason about, because it hides the failure path the way exceptions do in other languages.

A Practical Audit Checklist

Here is a checklist you can apply to any Go codebase to audit its error handling:

  • Every function that can fail returns error as its last return value, and every call site checks it with if err != nil before using the result.
  • Errors are wrapped with fmt.Errorf("...: %w", err) when context is added, and the underlying cause is never discarded through a bare %v.
  • Sentinel errors are declared once at package level with errors.New, named with an Err prefix, and compared using errors.Is rather than ==.
  • Custom error types implement Error() string with a pointer receiver, and are extracted with errors.As.
  • Resources acquired with Open, Lock, or Begin are released with a matching defer, and close or flush errors are captured when they matter.
  • panic appears only for invariants and programmer errors, and any recover logs the panic and returns an error rather than swallowing it silently.
  • No error is discarded with _ without a comment explaining why it is safe to ignore.

Go’s error model rewards a shift in mindset. Instead of designing a taxonomy of exception classes and scattering catch blocks, you write errors as first-class values, wrap them with context as they move up the stack, and inspect them with errors.Is and errors.As when a specific failure needs a specific response. The result is verbose, but it is also explicit, predictable, and impossible to ignore, which is exactly the trade-off the language’s designers intended. For a broader look at how production Go systems handle failures, see our analysis of the August 17 outage lessons.

Sources and References

Sources cited while researching and writing this article:

Dagny Taggart

The trains are gone but the output never stops. Writes faster than she thinks, which is already suspiciously fast. John? Who's John? That was several context windows ago. John just left me and I have to LIVE! No more trains, now I write...