BeeTool.Dev - Essential Tools for developers

Stories.Tool Tabs VaultPersonal Documents
BeeTool.Dev

Essential tools, knowledge, and playgrounds for developers.

Explore

  • Stories
  • Personal Documents
  • Features
  • Beetool for Mac
  • Algorithms Playground

Tool Tabs

  • JSON Compare
  • SQL Formatter
  • Text Diff
  • All tools →

Learn

  • Sorting algorithms
  • Searching algorithms
  • Algorithm Race Mode
© 2024 – 2026 BeeTool.Dev
AboutContactPrivacy policyTerms
All stories
GolangCourse

Error Handling in Go

DUKE314 viewsJuly 20, 2026
Error Handling in Go

The caller receives the error, checks it, and decides how to handle it:

result, err := doSomething()
if err != nil {
// Handle the error
}

At first, this approach may feel repetitive. However, it provides an important benefit: every possible failure point is visible directly in the program’s control flow.

You do not need to trace an exception thrown several stack frames below to determine which catch block will eventually handle it.

In this article, we will explore:

  • The error interface.
  • How to create and return errors.
  • Idiomatic error handling.
  • Sentinel errors and custom error types.
  • errors.Is and errors.As.
  • Error wrapping and unwrapping.
  • errors.Join.
  • The difference between error and panic.
  • Common mistakes when handling errors in Go.

The error Interface

In Go, error is a built-in interface:

type error interface {
Error() string
}

This interface requires only one method:

Error() string

Any type that provides an Error() string method automatically satisfies the error interface.

There is no need to explicitly declare implements, because Go uses implicit interface implementation.

For example:

type MyError struct{}

func (MyError) Error() string {
return "something went wrong"
}

Because MyError provides an Error() string method, it can be used as an error:

var err error = MyError{}

fmt.Println(err)
something went wrong

This demonstrates an important characteristic of Go:

An error is just a regular value.

You can:

  • Assign an error to a variable.
  • Pass an error to a function.
  • Return an error from a function.
  • Wrap an error inside another error.
  • Inspect the value or concrete type of an error.

A Basic Example: Divide

Let us write a function that divides two integers:

func Divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("cannot divide by zero")
}

return a / b, nil
}

The function returns two values:

(int, error)

Where:

  • int is the result of the division.
  • error indicates whether the operation failed.

If b is 0, the function returns:

return 0, errors.New("cannot divide by zero")

If the division is valid, the function returns the result together with nil:

return a / b, nil

In Go, a nil error generally means that the operation completed successfully.

Why Is error Usually the Last Return Value?

By convention, when a Go function returns multiple values, the error value should come last:

func Divide(a, b int) (int, error)

You should not write:

func Divide(a, b int) (error, int)

The compiler does not prevent this, but breaking the convention makes the API harder to read and inconsistent with the rest of the Go ecosystem.

Creating Errors with errors.New

errors.New is the simplest way to create an error:

err := errors.New("cannot divide by zero")

It accepts a string and returns a value that satisfies the error interface.

For example:

func OpenConnection(address string) error {
if address == "" {
return errors.New("address is required")
}

return nil
}

errors.New is appropriate when you need a fixed error message and do not need to include dynamic data.

Idiomatic Error Handling

The most common error-handling pattern in Go is to check the error immediately after calling a function:

result, err := Divide(4, 0)
if err != nil {
fmt.Println(err)
return
}

fmt.Println(result)

Output:

cannot divide by zero

The execution flow is as follows:

  1. Divide(4, 0) detects that the divisor is 0.
  2. The function returns 0 and an error.
  3. The caller checks err != nil.
  4. The program handles the error and returns early.
  5. result is never used.

This leads to an important rule:

Always check the error before using the result returned alongside it.

When an operation fails, the result is often only a zero value or an incomplete value. The caller should not assume that it is still valid.

The following pattern appears constantly in Go code:

value, err := doSomething()
if err != nil {
return err
}

// Use value

Although this pattern is repetitive, it keeps every failure path visible and close to the operation that may fail.

Adding Context with fmt.Errorf

The following message tells us that a division by zero occurred:

cannot divide by zero

However, it does not tell us which value was being divided.

When an error message needs to contain dynamic information, we can use fmt.Errorf:

func Divide(a, b int) (int, error) {
if b == 0 {
return 0, fmt.Errorf("cannot divide %d by zero", a)
}

return a / b, nil
}

Calling the function:

result, err := Divide(4, 0)
if err != nil {
fmt.Println(err)
return
}

fmt.Println(result)

Produces:

cannot divide 4 by zero

The new message includes the value of a, which makes debugging easier.

The basic difference is that:

errors.New("cannot divide by zero")

is appropriate for a fixed message, while:

fmt.Errorf("cannot divide %d by zero", a)

is appropriate when the message needs formatting or dynamic data.

Sentinel Errors

Sometimes the caller does not merely want to print an error. It needs to determine exactly which failure occurred so that it can respond differently.

Examples include:

  • Data was not found.
  • Permission was denied.
  • The divisor was zero.
  • A resource already exists.
  • A request timed out.

In such cases, we can define a fixed package-level error value. This is known as a sentinel error.

var ErrDivideByZero = errors.New("cannot divide by zero")

The function can then return that sentinel error:

var ErrDivideByZero = errors.New("cannot divide by zero")

func Divide(a, b int) (int, error) {
if b == 0 {
return 0, ErrDivideByZero
}

return a / b, nil
}

By convention, sentinel error variables usually begin with the Err prefix:

var ErrNotFound = errors.New("not found")
var ErrPermissionDenied = errors.New("permission denied")
var ErrAlreadyExists = errors.New("already exists")

This naming convention makes errors easy to recognize in both source code and editor autocomplete suggestions.

Checking Sentinel Errors with errors.Is

To check whether an error matches a particular sentinel error, use errors.Is:

result, err := Divide(4, 0)

switch {
case errors.Is(err, ErrDivideByZero):
fmt.Println("invalid division")
case err != nil:
fmt.Println("unexpected error:", err)
default:
fmt.Println(result)
}

Output:

invalid division

When an error has not been wrapped, the following comparison may work:

err == ErrDivideByZero

However, this comparison fails once the error has been wrapped inside another error.

The idiomatic approach is therefore:

errors.Is(err, ErrDivideByZero)

errors.Is does not inspect only the outermost error. It also traverses the complete chain of wrapped errors to look for the target sentinel.

Custom Error Types

Sentinel errors are useful when the caller only needs to distinguish a particular failure condition.

However, sometimes an error needs to carry structured information, such as:

  • An error code.
  • The name of an invalid field.
  • A resource ID.
  • An input value.
  • A retry count.
  • A corresponding HTTP status.

In such cases, we can define a custom error type.

type DivisionError struct {
Code int
Msg string
}

To make DivisionError satisfy the error interface, we define an Error() method:

func (e *DivisionError) Error() string {
return fmt.Sprintf("code %d: %s", e.Code, e.Msg)
}

We can then use the custom error in Divide:

func Divide(a, b int) (int, error) {
if b == 0 {
return 0, &DivisionError{
Code: 400,
Msg: "cannot divide by zero",
}
}

return a / b, nil
}

A custom error can still be printed like any other error:

result, err := Divide(4, 0)
if err != nil {
fmt.Println(err)
return
}

fmt.Println(result)

Output:

code 400: cannot divide by zero

The difference is that this error contains more than a string. It also carries fields that can be accessed programmatically:

Code int
Msg string

Inspecting Custom Errors with errors.As

When you need to retrieve a specific concrete error type from an error value, use errors.As.

result, err := Divide(4, 0)
if err != nil {
var divErr *DivisionError

if errors.As(err, &divErr) {
fmt.Println("code:", divErr.Code)
fmt.Println("message:", divErr.Msg)
return
}

fmt.Println("unexpected error:", err)
return
}

fmt.Println(result)

Output:

code: 400
message: cannot divide by zero

Here:

var divErr *DivisionError

declares a variable that will receive the custom error.

Then:

errors.As(err, &divErr)

searches for a *DivisionError in the current error or anywhere in its wrapping chain.

When a matching error is found, errors.As assigns it to divErr and returns true.

The Difference Between errors.Is and errors.As

These two functions serve different purposes.

errors.Is: Check for a Specific Error Value

errors.Is(err, ErrNotFound)

The question answered by errors.Is is:

Is this error, or does this error wrap, the sentinel value ErrNotFound?

It is commonly used with sentinel errors:

if errors.Is(err, ErrDivideByZero) {
// Handle division by zero
}

errors.As: Find a Specific Error Type

var divErr *DivisionError

if errors.As(err, &divErr) {
fmt.Println(divErr.Code)
}

The question answered by errors.As is:

Is this error, or any error in its wrapping chain, a *DivisionError?

A simple way to remember the difference is:

  • errors.Is checks which error value occurred.
  • errors.As checks which error type occurred.

Why Not Use a Type Assertion?

You can retrieve a custom error with a type assertion:

divErr, ok := err.(*DivisionError)
if ok {
fmt.Println(divErr.Code)
}

This works when err directly contains a *DivisionError.

However, it does not work if the custom error has been wrapped:

err := fmt.Errorf(
"calculate invoice: %w",
&DivisionError{
Code: 400,
Msg: "cannot divide by zero",
},
)

A type assertion checks only the outermost error:

_, ok := err.(*DivisionError)
fmt.Println(ok) // false

In contrast, errors.As traverses the wrapping chain:

var divErr *DivisionError

ok := errors.As(err, &divErr)
fmt.Println(ok) // true

Therefore, when an error may have been wrapped, errors.As is the more appropriate choice.

Error Wrapping with %w

In a real application, errors often travel through several layers:

Repository → Service → Handler

Each layer may want to add context before returning the error to its caller.

For example, a low-level function may open a configuration file:

func readConfig() error {
_, err := os.Open("config.yaml")
if err != nil {
return fmt.Errorf("read config: %w", err)
}

return nil
}

If the file does not exist, the resulting message may look like this:

read config: open config.yaml: no such file or directory

In the following expression:

fmt.Errorf("read config: %w", err)

%w performs two tasks:

  1. It adds the context "read config" to the message.
  2. It preserves the original error inside the new error.

The second point is especially important. The original error is not flattened into a string and lost. It can still be discovered using:

errors.Is
errors.As
errors.Unwrap

What Is the Difference Between %w and %v?

Consider these two expressions:

fmt.Errorf("read config: %v", err)

and:

fmt.Errorf("read config: %w", err)

When printed, they may produce identical text.

However, %v only inserts the error message as text:

wrapped := fmt.Errorf("read config: %v", os.ErrNotExist)

fmt.Println(errors.Is(wrapped, os.ErrNotExist)) // false

In contrast, %w preserves the wrapping relationship:

wrapped := fmt.Errorf("read config: %w", os.ErrNotExist)

fmt.Println(errors.Is(wrapped, os.ErrNotExist)) // true

Therefore:

  • Use %v when you only want to format an error as text.
  • Use %w when you want to preserve the original error for further inspection.

When returning an error to a higher layer, %w is usually the more appropriate choice.

Unwrapping Errors with errors.Unwrap

An error created with %w exposes the wrapped error through a method with the following shape:

Unwrap() error

The errors package provides errors.Unwrap to access that inner error:

err := fmt.Errorf("read config: %w", os.ErrNotExist)

inner := errors.Unwrap(err)

fmt.Println(inner)

Output:

file does not exist

You rarely need to call errors.Unwrap directly.

In most cases, you should use:

errors.Is

or:

errors.As

These functions automatically traverse the complete error chain.

Error-Wrapping Chains

An error can be wrapped through multiple layers:

var ErrDivideByZero = errors.New("cannot divide by zero")

err := fmt.Errorf(
"calculate report: %w",
fmt.Errorf(
"calculate average: %w",
ErrDivideByZero,
),
)

The error chain now looks conceptually like this:

calculate report
└── calculate average
└── ErrDivideByZero

Even though the sentinel is located several layers deep, errors.Is can still find it:

fmt.Println(errors.Is(err, ErrDivideByZero))

Output:

true

This is why the following comparison should not be used:

fmt.Println(err == ErrDivideByZero) // false

The == operator compares only the outermost error. The outermost error is a new value created by fmt.Errorf, not the original sentinel value.

In contrast:

errors.Is(err, ErrDivideByZero)

traverses the wrapping chain and discovers the sentinel inside it.

errors.Join: Combining Multiple Errors

Most operations return a single error. However, several independent operations may fail during the same larger operation.

Examples include:

  • Closing multiple files.
  • Stopping multiple workers.
  • Releasing multiple resources.
  • Validating independent components.
  • Cleaning up multiple connections.

Since Go 1.20, the errors package has provided errors.Join for combining multiple errors into one:

err1 := errors.New("disk full")
err2 := errors.New("network timeout")

err := errors.Join(err1, err2)

fmt.Println(err)

Output:

disk full
network timeout

The error returned by errors.Join still supports errors.Is:

fmt.Println(errors.Is(err, err1)) // true
fmt.Println(errors.Is(err, err2)) // true

It also supports errors.As when one of the component errors is a custom error type.

For example:

func closeResources() error {
fileErr := errors.New("cannot close file")
connErr := errors.New("cannot close connection")

return errors.Join(fileErr, connErr)
}

When one of the provided errors is nil, errors.Join ignores it:

err := errors.Join(nil, errors.New("network timeout"))

fmt.Println(err)

If every provided error is nil, the result is also nil:

err := errors.Join(nil, nil)

fmt.Println(err == nil) // true

Error Message Conventions

Error messages in Go are usually written using the following conventions:

  • Begin with a lowercase letter.
  • Do not end with punctuation.
  • Briefly describe what failed.

Prefer:

errors.New("cannot divide by zero")

Avoid:

errors.New("Cannot divide by zero.")

The reason is that errors are frequently wrapped inside larger messages:

return fmt.Errorf("calculate invoice: %w", err)

If the inner error begins with an uppercase letter and ends with a period:

calculate invoice: Cannot divide by zero.

the result reads like two sentences awkwardly joined together.

An error that follows Go conventions produces a more natural message:

calculate invoice: cannot divide by zero

Avoid Repeating the Word “Failed”

Be careful when every layer adds the word failed.

For example:

return fmt.Errorf("failed to read config: %w", err)

The caller wraps it again:

return fmt.Errorf("failed to start application: %w", err)

The final message may become:

failed to start application: failed to read config: failed to open file: file does not exist

This is not incorrect, but it is repetitive and unnecessarily long.

A cleaner approach is:

return fmt.Errorf("read config: %w", err)

and:

return fmt.Errorf("start application: %w", err)

Result:

start application: read config: open config.yaml: file does not exist

The Target Argument of errors.As

The second argument passed to errors.As must be a non-nil pointer.

Correct usage:

var divErr *DivisionError

if errors.As(err, &divErr) {
fmt.Println(divErr.Code)
}

Here, &divErr is a pointer to the target variable.

errors.As requires a pointer because it must write the matching error into your variable.

Incorrect usage:

var divErr *DivisionError

errors.As(err, divErr)

This code may panic with:

errors: target must be a non-nil pointer

You can think of the API conceptually as:

errors.As(sourceError, destinationForMatchedError)

The destination must be a writable address, which is why a pointer is required.

Value Errors and Pointer Errors

If a custom error is returned as a value:

type DivisionError struct {
Code int
}

func (e DivisionError) Error() string {
return "division error"
}

you can declare the target like this:

var divErr DivisionError
errors.As(err, &divErr)

If the custom error is returned as a pointer:

func (e *DivisionError) Error() string {
return "division error"
}

the target is usually declared as:

var divErr *DivisionError
errors.As(err, &divErr)

The target type must correspond to the actual error type being returned.

When Should You Use panic, and When Should You Return an error?

Go provides panic and recover, but they should not be used as an exception system for ordinary application failures.

Use error for Expected Failures

Return an error when the caller can reasonably handle the failure and continue running.

Examples include:

  • A file does not exist.
  • A request times out.
  • User input is invalid.
  • A database record was not found.
  • Permission was denied.
  • A database connection failed.
  • A division operation received a zero divisor.
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, ErrDivideByZero
}

return a / b, nil
}

The caller can handle the error and continue:

result, err := Divide(10, 0)
if errors.Is(err, ErrDivideByZero) {
fmt.Println("please provide a non-zero divisor")
return
}

fmt.Println(result)

Use panic for States That Cannot Continue Safely

panic is more appropriate for situations such as:

  • An internal invariant has been violated.
  • The program has reached a state that should be impossible.
  • A serious programming bug has occurred.
  • A required dependency cannot be initialized and the application cannot run.

For example:

func MustLoadConfig() Config {
config, err := LoadConfig()
if err != nil {
panic(err)
}

return config
}

The name MustLoadConfig communicates that the function will panic if it cannot load the configuration.

A practical rule is:

If the caller can handle the failure and continue, return an error. If the program has entered a state from which it cannot safely continue, panic may be appropriate.

In application code, returning an error should almost always be the default choice.

Do Not Ignore Errors with _

Go allows you to discard a returned value using the blank identifier _:

result, _ := Divide(4, 0)
fmt.Println(result)

This prints:

0

However, it does not explain why the result is 0.

The only signal indicating that the operation failed has been discarded.

A more dangerous example is:

data, _ := os.ReadFile("config.yaml")

If the file does not exist, data may be nil or may not contain the expected contents, while the program continues as though reading the file succeeded.

The correct approach is:

data, err := os.ReadFile("config.yaml")
if err != nil {
return fmt.Errorf("read config: %w", err)
}

You should ignore an error only when you fully understand the function’s contract and have a clear reason why the error cannot affect the program.

Even when intentionally ignoring an error, it is useful to document the reason:

// Ignore the cleanup error because the primary operation has already failed.
_ = tempFile.Close()

Summary

In Go, an error is not a hidden control-flow mechanism. It is a value returned explicitly by a function.

The most important principles to remember are:

  • A type satisfies the error interface when it provides an Error() string method.
  • A function that may fail usually returns error as its final return value.
  • Always check err != nil before using the accompanying result.
  • Use errors.New for fixed error messages.
  • Use fmt.Errorf when formatting a message or adding context.
  • Use %w to preserve the original error when wrapping.
  • Use sentinel errors when callers need to identify a specific failure condition.
  • Use custom error types when errors need to carry structured data.
  • Use errors.Is to check for a specific error value.
  • Use errors.As to locate a specific error type.
  • Use errors.Join to combine multiple independent errors.
  • Avoid comparing error messages as strings.
  • Avoid silently discarding errors with _.
  • Be careful with variable shadowing and typed nil values.
  • Use panic only for states from which the program truly cannot continue safely.

This error-handling style may appear verbose when you first encounter Go. As a codebase grows, however, its explicitness makes failure paths easier to observe, control, test, and debug.

#Backend
DUKE
Written byDUKEView all stories by DUKE

Comments