How to Log and Handle Errors Properly in Go

Log errors with context using log.Printf and handle them by returning or exiting to prevent silent failures.

When a missing file crashes your service

You're writing a service that reads a configuration file at startup. The file is missing. Your code catches the error, prints a tiny message to stderr, and keeps running. Ten minutes later, the service tries to connect to a database using an empty host string. It panics. The crash log shows nothing about the config file. You spent an hour debugging a symptom instead of the cause.

This happens when errors get treated as suggestions instead of signals. Go makes it hard to ignore errors by design. The language forces you to look at them. The question is what to do once you're looking.

Errors are values, not exceptions

In Go, an error is just a value. It implements the error interface, which has one method: Error() string. That's it. There are no try-catch blocks. There are no thrown exceptions that jump up the stack. When a function fails, it returns an error value alongside its normal result. The caller checks that value and decides what to do.

This design keeps the control flow explicit. You can see every failure path by reading the code top to bottom. It also means errors can be passed around, wrapped, and inspected like any other data. The trade-off is verbosity. You write if err != nil a lot. The community accepts this boilerplate because it makes the unhappy path visible. You never wonder if a function might fail. The signature tells you.

Errors are values. Treat them like data.

Minimal pattern

Here's the pattern you'll see everywhere. Open a file, check the error, return or handle.

package main

import (
	"fmt"
	"os"
)

// readFile loads a file and returns its contents.
// It returns an error if the file cannot be read.
func readFile(path string) ([]byte, error) {
	// os.ReadFile handles opening, reading, and closing.
	// It returns the data and any error that occurred.
	data, err := os.ReadFile(path)
	if err != nil {
		// Bubble the error up to the caller.
		// Don't swallow it or print and return nil.
		return nil, err
	}
	return data, nil
}

func main() {
	// Attempt to read the file.
	// The error value tells us if something went wrong.
	content, err := readFile("config.txt")
	if err != nil {
		// Print to stderr and exit with non-zero code.
		// This signals failure to the shell or orchestrator.
		fmt.Fprintf(os.Stderr, "read failed: %v\n", err)
		os.Exit(1)
	}
	fmt.Println(string(content))
}

Check the error. Return the error. Repeat.

Walk through what happens

When the compiler sees err in the return type, it enforces that every code path returns a value for it. If you add a new return statement and forget the error, the compiler rejects the program with not enough return values. This catches bugs early.

At runtime, os.ReadFile checks the file system. If the file doesn't exist, it returns a non-nil error. The if err != nil check evaluates to true. The function returns nil for the data and the error for the error. The caller receives both. The caller checks the error, sees it's not nil, and executes the failure branch. The program exits with code 1. The shell sees the non-zero exit code and knows the command failed.

This chain of responsibility is the core of Go error handling. Each layer adds context or decides to stop.

Realistic wrapping and sentinels

Real code rarely just returns the raw error. You add context so the caller knows where the failure happened. Wrapping errors preserves the original cause while adding a message.

package main

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

// ErrEmptyConfig indicates the configuration file is empty.
// Define sentinel errors at package level for checking.
var ErrEmptyConfig = errors.New("config file is empty")

// loadConfig reads and parses the configuration file.
// It wraps errors to add context about the operation.
func loadConfig(path string) (map[string]string, error) {
	// Read the file bytes.
	data, err := os.ReadFile(path)
	if err != nil {
		// Wrap the error with context.
		// %w marks the error as wrap-able for errors.Is/As.
		return nil, fmt.Errorf("load config: %w", err)
	}

	// Check for empty content.
	if len(data) == 0 {
		// Return the sentinel error.
		// Callers can use errors.Is to detect this case.
		return nil, ErrEmptyConfig
	}

	// Parse the data into a map.
	config := make(map[string]string)
	config["version"] = "1.0"
	return config, nil
}

func handleRequest(w http.ResponseWriter, r *http.Request) {
	// Load config inside the handler.
	config, err := loadConfig("config.json")
	if err != nil {
		// Check if the error is the empty config case.
		// errors.Is unwraps the chain to find the match.
		if errors.Is(err, ErrEmptyConfig) {
			http.Error(w, "missing configuration", http.StatusServiceUnavailable)
			return
		}
		// Log the error with context.
		fmt.Fprintf(os.Stderr, "handler error: %v\n", err)
		http.Error(w, "internal server error", http.StatusInternalServerError)
		return
	}

	// Use the config.
	fmt.Fprintf(w, "Version: %s", config["version"])
}

Wrap errors with %w. Check with errors.Is.

Structured logging in production

Text logs are hard to parse at scale. Go 1.21 introduced log/slog for structured logging. It supports levels, attributes, and handlers that output JSON. This makes logs machine-readable for aggregators.

package main

import (
	"log/slog"
	"os"
)

func main() {
	// Configure slog to output JSON to stderr.
	// This makes logs machine-readable for aggregators.
	logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))

	// Log an error with context attributes.
	// Attributes provide structured metadata alongside the message.
	logger.Error("failed to open file",
		slog.String("path", "data.txt"),
		slog.Any("error", os.ErrNotExist),
	)
}

Log structured data. Parse logs like code.

Pitfalls and compiler traps

The most common mistake is ignoring the error. If you write result := doSomething() and doSomething returns (T, error), the compiler rejects this with assignment mismatch: 1 variable but doSomething returns 2 values. You can't accidentally drop the error. You have to use _ to discard it intentionally. result, _ := doSomething() compiles, but it's a code smell. Use the blank identifier only when you are sure the error is irrelevant, like closing a writer that you already flushed.

A subtle bug happens when you return a typed error that is nil. If your function returns error, and you have a variable var err *CustomError = nil, returning err gives the caller a non-nil interface value. The interface holds the type *CustomError and a nil pointer. The check if err != nil evaluates to true, even though the error is logically nil. Always return nil directly, or use errors.New / fmt.Errorf. Never return a nil pointer wrapped in an interface.

Another pitfall is returning a nil error along with a non-nil value that indicates failure. If a function returns (int, error), and it returns 0, nil, the caller assumes success and gets zero. If zero is a valid error state, the caller can't distinguish. Return a non-nil error if the operation failed, even if the value is zero.

Avoid panicking for control flow. Panics are for truly unrecoverable bugs, like a nil pointer dereference or a missing invariant. If a file is missing, that's an error, not a panic. Panicking crashes the goroutine and prints a stack trace. It's hard to recover from. Use errors for expected failures. Use panics for programming mistakes.

The worst error is the one that looks like nil but isn't.

Decision matrix

Use return err when the function cannot proceed and the caller needs to know the raw cause.

Use fmt.Errorf("context: %w", err) when you need to add location or operation details while preserving the error chain.

Use errors.New("message") when you define a sentinel error that callers can check with errors.Is.

Use log.Printf or log/slog when the error is terminal and the program must stop, or when you need to record the event for debugging.

Use panic only when the program state is corrupted and continuing would cause undefined behavior.

Use os.Exit(1) in main when a fatal error occurs and there is no recovery path.

Use the blank identifier _ to discard an error only when the error is guaranteed to be harmless, such as closing a standard output stream.

Use the right tool for the failure mode.

Where to go next