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
errorinterface. - How to create and return errors.
- Idiomatic error handling.
- Sentinel errors and custom error types.
errors.Isanderrors.As.- Error wrapping and unwrapping.
errors.Join.- The difference between
errorandpanic. - 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 wrongThis 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:
intis the result of the division.errorindicates 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 zeroThe execution flow is as follows:
Divide(4, 0)detects that the divisor is0.- The function returns
0and an error. - The caller checks
err != nil. - The program handles the error and returns early.
resultis 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 zeroHowever, 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 zeroThe 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 divisionWhen an error has not been wrapped, the following comparison may work:
err == ErrDivideByZeroHowever, 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 zeroThe 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.Ischecks which error value occurred.errors.Aschecks 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 → HandlerEach 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 directoryIn the following expression:
fmt.Errorf("read config: %w", err)
%w performs two tasks:
- It adds the context
"read config"to the message. - 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
%vwhen you only want to format an error as text. - Use
%wwhen 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 existYou rarely need to call errors.Unwrap directly.
In most cases, you should use:
errors.Isor:
errors.AsThese 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:
trueThis is why the following comparison should not be used:
fmt.Println(err == ErrDivideByZero) // falseThe == 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 zeroAvoid 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 existThis 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 existThe 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 pointerYou 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,panicmay 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:
0However, 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
errorinterface when it provides anError() stringmethod. - A function that may fail usually returns
erroras its final return value. - Always check
err != nilbefore using the accompanying result. - Use
errors.Newfor fixed error messages. - Use
fmt.Errorfwhen formatting a message or adding context. - Use
%wto 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.Isto check for a specific error value. - Use
errors.Asto locate a specific error type. - Use
errors.Jointo 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
paniconly 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.
DUKE
Comments