How does error handling work in Go

Go handles errors by returning them as a second value that callers must explicitly check before proceeding.

Errors are values, not exceptions

You write a function to fetch user data from a database. In Python, you wrap the call in a try block and hope the except clause catches the connection timeout. In JavaScript, you chain a .catch() or nest a try/catch. Go throws that model out the window. There are no exceptions. There is no try. There is no catch.

Errors in Go are just values, like integers or strings. Functions return errors as a second return value. You check that value immediately. If it indicates a problem, you handle it right there. The program flow stays linear and predictable. You decide what happens when things go wrong.

The receipt analogy

Think of exceptions as a fire alarm. When the alarm rings, everyone stops what they're doing and runs to the exit. The normal flow of work halts immediately, and control jumps to a handler somewhere else in the building.

Go treats errors more like a return receipt on a package. You send a request, and the function sends back two things: the result and a receipt. If the receipt says "delivered," you use the result. If the receipt says "address unknown," you deal with that problem at the counter. The program doesn't stop. You inspect the receipt, decide whether to retry, log a message, or return the error to your caller, and continue.

Errors are values. Handle them where they happen.

Minimal example

Every function that can fail returns an error as its last return value. The error type is an interface defined in the standard library. When a function succeeds, it returns nil for the error. When it fails, it returns a non-nil error value.

package main

import (
	"fmt"
	"os"
)

// OpenFile opens a file and returns the handle and any error.
// It demonstrates the standard error return pattern.
func OpenFile(name string) (*os.File, error) {
	// os.Open returns two values: the file handle and an error.
	// The error is always the last return value.
	file, err := os.Open(name)
	if err != nil {
		// If err is not nil, something went wrong.
		// We return immediately to avoid using a nil file.
		// Returning nil for the file signals failure to the caller.
		return nil, err
	}
	// Only reach here if err is nil.
	// The file is valid and ready to use.
	return file, nil
}

func main() {
	// Call the function and capture both return values.
	f, err := OpenFile("config.txt")
	if err != nil {
		// Handle the error at the call site.
		// You cannot ignore this error; the compiler enforces it.
		fmt.Println("Failed to open:", err)
		return
	}
	// Use f safely.
	// The error check guarantees f is not nil.
	fmt.Println("Opened:", f.Name())
}

What happens under the hood

The error type is an interface. Its definition is simple:

type error interface {
	Error() string
}

Any type that implements an Error() string method is an error. This means the standard library can return different error types, and you can create your own. The interface allows Go to pass errors around without exposing the underlying implementation details.

If you call a function that returns an error and you don't use it, the compiler rejects the code with err declared and not used. You have to acknowledge the error. You can assign it to _ to discard it intentionally. result, _ := ... says "I considered the second return value and chose to drop it". Use the underscore sparingly with errors. Discarding an error without a comment is a code smell.

The if err != nil pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. You can't accidentally ignore an error. The control flow is explicit.

Errors are values. Treat them like data.

The error interface and custom types

You can create custom error types by defining a struct and implementing the Error method. This is useful when you need to attach structured data to an error, like a status code or a request ID.

package main

import (
	"fmt"
)

// ValidationError represents a problem with user input.
// Public fields start with a capital letter.
type ValidationError struct {
	Field string
	Msg   string
}

// Error implements the error interface.
// The receiver name is e, matching the type convention.
// Receiver names are usually one or two letters.
func (e ValidationError) Error() string {
	return fmt.Sprintf("validation failed for %s: %s", e.Field, e.Msg)
}

func main() {
	// Create a custom error.
	err := ValidationError{Field: "email", Msg: "invalid format"}
	
	// Print the error.
	// The Error method is called automatically.
	fmt.Println(err)
	
	// Output: validation failed for email: invalid format
}

Public names start with a capital letter. Private names start lowercase. There are no keywords like public or private. The capitalization controls visibility across packages. The receiver name is usually one or two letters matching the type: (e ValidationError), not (this ValidationError) or (self ValidationError).

Accept interfaces, return structs. The error type is an interface. You return structs that implement it. This keeps your API flexible and allows callers to check for specific types if needed.

Don't fight the type system. Implement the method or change the design.

Realistic example: wrapping and context

In real code, you rarely return raw errors. You wrap them with context. Wrapping adds information about where the error occurred without losing the original cause. Modern Go uses fmt.Errorf with the %w verb to wrap errors.

package main

import (
	"context"
	"fmt"
	"net/http"
	"os"
)

// ServeConfig reads a config file and writes it to the response.
// It wraps errors to add context about the operation.
func ServeConfig(w http.ResponseWriter, r *http.Request) {
	// context.Context always goes as the first parameter.
	// Functions that take a context should respect cancellation.
	ctx := r.Context()

	// Check if the request was cancelled before doing work.
	if err := ctx.Err(); err != nil {
		// Return early if the client disconnected.
		return
	}

	// Read the file content.
	data, err := os.ReadFile("config.json")
	if err != nil {
		// Wrap the error with context.
		// %w wraps the error so callers can unwrap it later.
		// This preserves the error chain.
		err = fmt.Errorf("read config: %w", err)
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	// Write the content.
	w.Header().Set("Content-Type", "application/json")
	w.Write(data)
}

Wrapping creates a chain of errors. Callers can check if the root cause matches a specific error using errors.Is. This keeps the stack trace clean and adds meaning at each layer. fmt.Errorf("operation: %w", err) creates a new error that contains the original error.

context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. Checking ctx.Err() prevents wasted work when a request is cancelled.

Wrap errors. Context matters.

Pitfalls and compiler errors

A common mistake is comparing errors with ==. If an error is wrapped, err == os.ErrNotExist returns false. You must use errors.Is(err, os.ErrNotExist) to check for specific errors in a chain. The compiler won't stop you from using ==, but your code will fail silently.

If you try to pass an error where a string is expected, the compiler rejects the code with cannot use err (type error) as string value in argument. You need to call err.Error() or use fmt to format it.

Another trap is log.Fatal. This function prints the error and exits the program. It does not run deferred functions. If you need cleanup, use defer before the fatal call, or handle the error gracefully and return. This is a critical distinction for developers coming from languages where exceptions unwind the stack automatically.

// BAD: Continuing after error.
file, err := os.Open("data.txt")
// Forgot to check err!
content, _ := io.ReadAll(file) // Panics if file is nil.

If you forget to check the error and use the result, you get a runtime panic: panic: runtime error: invalid memory address or nil pointer dereference. The compiler can't catch this. You have to check errors manually.

The worst error bug is the one that gets swallowed.

When to use what

Use if err != nil for immediate handling of errors returned by functions.

Use fmt.Errorf("context: %w", err) to wrap errors with additional information as they bubble up the call stack.

Use errors.Is(err, target) to check for specific error types, including wrapped errors.

Use errors.As(err, &target) to extract a specific type from a wrapped error chain.

Use log.Fatal or log.Panic only at the top level of your program, such as in main, to terminate execution on unrecoverable failures.

Use custom error types when you need to attach structured data to an error, like a status code or a request ID.

Explicit handling beats implicit magic.

Where to go next