How to Return and Check Errors in Go

In Go, functions return errors as a second (or final) return value, and you must explicitly check them using `if err != nil` before proceeding.

Errors are values, not exceptions

You are writing a function to parse a user's age from a string. The user types "twelve". In Python, you might get a ValueError that crashes your script unless you wrap the call in a try block. In JavaScript, you might get undefined or a silent failure that breaks logic three layers down. Go takes a different approach. The function returns the age and an error value. You get both. You have to look at the error before you trust the age.

Go treats errors like any other value. A function can return a result and an error. The error is just a value that tells you if the operation succeeded. There are no exceptions. There is no stack unwinding triggered by a runtime panic unless you explicitly ask for one. Errors flow back through return values, and you handle them where you have the context to do something useful.

Think of a vending machine. You put in money and select a snack. The machine gives you the snack and a receipt. If the machine is out of stock, it gives you your money back and a note saying "Out of stock." You have to read the note to know you didn't get a snack. The note isn't magic. It's just paper. The error isn't magic. It's just a value.

The standard pattern

Here is the simplest pattern: a function returns two values, and the caller checks the second one immediately.

package main

import (
	"fmt"
)

// Divide returns the quotient of a and b, or an error if b is zero.
func Divide(a, b float64) (float64, error) {
	if b == 0 {
		// Return a zero value for the result and a descriptive error.
		return 0, fmt.Errorf("division by zero")
	}
	// Return the calculated result and nil to signal success.
	return a / b, nil
}

func main() {
	// Call the function and capture both return values.
	result, err := Divide(10, 0)

	// Check the error immediately. If it's not nil, something went wrong.
	if err != nil {
		fmt.Printf("Error: %v\n", err)
		return
	}

	// Only use the result if the error is nil.
	fmt.Printf("Result: %v\n", result)
}

The function Divide declares two return values: float64 and error. Inside the function, return 0, fmt.Errorf(...) provides both. The caller uses := to capture them into result and err. The check if err != nil is the gate. If the error is not nil, the code handles the failure and stops. If the error is nil, the code proceeds to use result.

This pattern is verbose by design. The Go community accepts the boilerplate because it makes the unhappy path visible. You cannot accidentally skip an error check. The compiler forces you to capture the return values. If you write Divide(10, 0) without capturing the results, the compiler rejects the program with multiple return values used as single value. You must assign the results to variables or discard them.

Errors are values. Check them like values.

The error interface

The error type is an interface defined in the standard library:

type error interface {
	Error() string
}

Any type that implements a method Error() string satisfies the error interface. This means you can create your own error types. It also means nil is a valid error value. nil represents the absence of an error. When a function succeeds, it returns nil for the error slot.

The interface is small. It only requires a string representation. This keeps error handling lightweight. You can print an error, log it, or return it without worrying about complex structures. When you need more structure, you create a custom type that implements the interface.

The interface is tiny. The implementation is yours.

Wrapping errors with context

Real code often calls other functions that can fail. You need to add context so the caller knows where the failure happened. Go 1.13 introduced error wrapping. You use fmt.Errorf with the %w verb to wrap an error. This preserves the original error inside a new error that adds context.

Here is a realistic example reading a config file.

package main

import (
	"fmt"
	"os"
)

// ReadConfig reads the file at path and returns the content as a string.
// It wraps any OS errors with context about the config file.
func ReadConfig(path string) (string, error) {
	// ReadFile returns the data and an error if the file is missing or unreadable.
	data, err := os.ReadFile(path)
	if err != nil {
		// Wrap the error with %w to preserve the original error for checking later.
		// Add context so the caller knows this failed while reading the config.
		return "", fmt.Errorf("read config %s: %w", path, err)
	}
	// Convert bytes to string and return success.
	return string(data), nil
}

func main() {
	content, err := ReadConfig("missing.conf")
	if err != nil {
		fmt.Printf("Failed: %v\n", err)
		return
	}
	fmt.Printf("Config: %s\n", content)
}

The %w verb tells fmt.Errorf to wrap the error. The resulting error contains the message "read config missing.conf: ..." and holds a reference to the original os error. You can unwrap this chain later to check for specific causes. The output shows the full chain: Failed: read config missing.conf: open missing.conf: no such file or directory.

Wrap errors with context. The caller deserves to know where things broke.

Pitfalls and compiler rules

Go's error handling has a few traps. The compiler catches some, but others require careful code.

If you assign an error to a variable but never use it, the compiler rejects the program with err declared and not used. You cannot ignore errors silently. If you want to discard an error intentionally, use the blank identifier _. The expression result, _ := Divide(10, 2) tells the compiler you considered the error and chose to drop it. Use this sparingly. Discarding errors is usually a sign that you should handle them or return them.

A common runtime bug is the typed nil error. If you return a nil pointer of a specific error type, the error value is not nil. The interface holds a type and a nil pointer. The check if err != nil evaluates to true even though the underlying value is nil. This happens when you write return nil, (*MyError)(nil). Always return nil directly for the error slot, not a typed nil.

Another pitfall is comparing error strings. Do not write if err.Error() == "file not found". Error messages can change between versions or translations. Use errors.Is to check for specific errors. errors.Is walks the error chain and compares against sentinel errors like os.ErrNotExist. It is safe and robust.

The compiler won't let you ignore errors by accident. Capture them or discard them explicitly.

Checking specific errors

When you need to handle a specific failure case, use errors.Is or errors.As. errors.Is checks if an error matches a sentinel error. errors.As checks if an error in the chain matches a specific type and extracts it.

import (
	"errors"
	"os"
)

func HandleFile(path string) error {
	_, err := os.ReadFile(path)
	if errors.Is(err, os.ErrNotExist) {
		// Handle the case where the file is missing.
		return fmt.Errorf("create new file %s", path)
	}
	if err != nil {
		// Handle other errors.
		return err
	}
	return nil
}

errors.Is works with wrapped errors. If ReadFile returns a wrapped error that contains os.ErrNotExist, errors.Is finds it. You do not need to unwrap manually. errors.As is similar but extracts a type. If you have a custom error type with extra fields, errors.As lets you access those fields.

Use errors.Is for sentinel values. Use errors.As for custom types.

Custom error types

Sometimes a string is not enough. You need to attach structured data to an error. Create a struct with an Error() string method. This makes the struct an error.

// ValidationError represents a problem with user input.
type ValidationError struct {
	Field   string
	Message string
}

// Error implements the error interface.
func (e *ValidationError) Error() string {
	return fmt.Sprintf("validation failed for %s: %s", e.Field, e.Message)
}

func ValidateAge(age int) error {
	if age < 0 {
		// Return a custom error with structured data.
		return &ValidationError{
			Field:   "age",
			Message: "must be non-negative",
		}
	}
	return nil
}

The receiver name is usually one or two letters matching the type. Here, e matches ValidationError. The method returns a formatted string. Callers can use errors.As to extract the ValidationError and read the Field and Message.

Custom errors let you pass rich information up the call stack. The caller can decide whether to log the details or just show a summary.

When to use what

Go provides several tools for error handling. Pick the right one for the situation.

Use if err != nil when you need to handle a failure right after a call. Use fmt.Errorf with %w when you want to add context to an error while preserving the original cause. Use errors.Is when you need to check if an error matches a specific sentinel value like os.ErrNotExist. Use errors.As when you need to extract a custom error type to access extra fields. Use the blank identifier _ when you intentionally want to discard a return value. Use a custom error type when you need to attach structured data to an error. Use panic only when the program cannot continue and recovery is impossible.

Check errors early. Wrap with context. Compare with helpers.

Where to go next