How Error Handling Works in Go (No Exceptions)

Go handles errors by returning them as explicit values that developers must check, avoiding the complexity and hidden control flow of exceptions.

Errors are values, not interruptions

You write a function in Python that reads a configuration file. You forget to handle the case where the file is missing. The program crashes with a FileNotFoundError three layers deep in the call stack. You add a try/except block, but now you're catching errors you didn't expect, and the stack trace is still a maze. Go takes a different path. Errors are values. They travel up the call stack like any other return value, and you handle them where you have the context to do something useful.

Think of an error like a receipt from a transaction. In languages with exceptions, the receipt gets thrown across the room when something goes wrong, and someone has to catch it before it hits the floor. In Go, the receipt comes back in your hand. You look at it. If it says "OK", you proceed. If it says "Declined", you decide right then whether to ask for a different card, tell the customer, or log the issue. The error doesn't interrupt the flow of the program. It is just data you inspect.

The error interface

Go defines errors with a single interface in the standard library.

// The error interface requires one method.
// Any type implementing Error() string is an error.
type error interface {
    Error() string
}

This design follows the Go mantra: accept interfaces, return structs. A function signature returns error, which is an interface. The implementation returns a concrete struct that satisfies the interface. The caller checks err != nil without caring about the concrete type. This keeps the API flexible. You can return a simple string error for quick failures or a rich struct with fields for detailed diagnostics. The caller only sees the interface.

Python developers often complain about the repetition of if err != nil. The repetition is intentional. It forces you to look at every potential failure point. In an exception-based language, a function might fail in five different ways, and you only see the try/except block. In Go, the signature lists the return values, and the code shows the check. You cannot miss an error unless you explicitly discard it. The verbosity is the feature. It makes the unhappy path visible in the source code.

Minimal example

Here is the standard pattern for opening a file.

package main

import (
    "fmt"
    "os"
)

// OpenFile opens a file and returns the handle or an error.
func OpenFile(path string) (*os.File, error) {
    // os.Open returns two values: the file handle and an error.
    f, err := os.Open(path)
    if err != nil {
        // If the error is not nil, the open failed.
        // Return immediately so the caller knows.
        return nil, err
    }
    // Only reach here if err is nil.
    return f, nil
}

func main() {
    // Call the function and capture both return values.
    file, err := OpenFile("config.txt")
    if err != nil {
        // Handle the error where you have context.
        fmt.Println("Could not open config:", err)
        return
    }
    // Use the file safely.
    fmt.Println("File opened successfully:", file.Name())
    file.Close()
}

The if err != nil check is the standard pattern. The community accepts the boilerplate because it makes control flow explicit. gofmt formats these blocks consistently. Don't argue about indentation or brace placement. Let the tool decide. Most editors run gofmt on save, so your code matches the rest of the ecosystem without debate.

Walkthrough

When os.Open runs, it tries to open the file. If the file exists and permissions allow, it returns a valid *os.File and nil for the error. If the file is missing, it returns nil for the file and a non-nil error describing what went wrong.

The compiler forces you to acknowledge the error value. If you try to use the file handle before checking the error, the compiler rejects the program with file used before declaration. This prevents you from accidentally using a nil pointer. You can assign the error to _ to discard it, but that is a signal to other developers that you intentionally ignored the result. Use _ sparingly with errors. Discarding an error should be a conscious decision, not a default.

Realistic example: Wrapping and context

In real applications, you rarely return raw errors. You wrap them to add context. Go 1.13 introduced error wrapping with fmt.Errorf and the %w verb. This preserves the error chain so you can unwrap it later.

package main

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

// ReadConfig reads the config file and returns the content.
// It wraps errors to provide context about the operation.
func ReadConfig(path string) ([]byte, error) {
    // os.ReadFile returns the bytes and an error.
    data, err := os.ReadFile(path)
    if err != nil {
        // Wrap the error with context using fmt.Errorf.
        // The %w verb allows unwrapping later.
        return nil, fmt.Errorf("reading config %s: %w", path, err)
    }
    return data, nil
}

// HandleRequest simulates an HTTP handler using config.
func HandleRequest(w http.ResponseWriter, r *http.Request) {
    // Read the config inside the handler.
    config, err := ReadConfig("app.conf")
    if err != nil {
        // Log the error and return a 500 status.
        // In a real app, you might use a logger instead of fmt.
        fmt.Fprintf(w, "Internal Server Error: %v", err)
        w.WriteHeader(http.StatusInternalServerError)
        return
    }
    // Use config safely.
    fmt.Fprintf(w, "Config loaded: %d bytes", len(config))
}

Wrapping errors adds a breadcrumb trail. When the error reaches the top level, the message includes the path and the original cause. The caller can still check the underlying error using errors.Is or errors.As. This keeps diagnostics useful without cluttering the code with custom error types for every function.

Functions that accept a context.Context should check for cancellation. If ctx.Err() is not nil, the caller has cancelled the operation. Return the context error immediately. This prevents goroutine leaks and wasted work. Context is plumbing. Run it through every long-lived call site.

Pitfalls and compiler errors

Panic is for unrecoverable errors. Don't use panic for expected failures like missing files or bad input. Panic stops the goroutine and unwinds the stack. Use it only when the program is in a state where continuing is impossible, such as a failed invariant or a missing resource required at startup. The worst goroutine bug is the one that never logs. If you panic, ensure the panic message contains enough detail to diagnose the issue.

Comparing errors requires care. You cannot use == to compare wrapped errors. Use errors.Is to check if an error matches a specific sentinel value. Use errors.As to extract a custom error type from a wrapped chain.

import "errors"

// Check if the error is a specific sentinel error.
if errors.Is(err, os.ErrNotExist) {
    // Handle missing file case.
}

// Extract a custom error type.
var validationErr *ValidationError
if errors.As(err, &validationErr) {
    // Access validationErr.Field.
}

If you forget to import a package that defines an error, the compiler rejects the program with undefined: pkg. If you import a package and don't use it, you get imported and not used. These errors keep your dependencies clean.

Custom errors should implement the Error() string method. The receiver name is usually one or two letters matching the type. Use (e *ValidationError) Error(), not (this *ValidationError). This matches the convention across the standard library.

Decision matrix

Use if err != nil when you need to handle a specific error or stop execution immediately. Use fmt.Errorf with %w when you want to add context to an error while preserving the original error chain. 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 from a wrapped error chain. Use panic only when the program cannot continue safely, such as a failed invariant or a missing resource required at startup. Use _ to discard an error only when you have verified the operation cannot fail or the error is irrelevant to your logic.

Errors are values. Treat them like data, not interruptions. Wrap errors with context. The caller deserves to know where the failure happened. Panic is the nuclear option. Reserve it for when the program is already dead.

Where to go next