Skip to main content
  1. Languages/
  2. Golang Guides/

Mastering Error Handling in Go: Patterns for Robust Applications

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

If there is one idiom that defines Go development, it’s if err != nil. For newcomers, it can feel repetitive. For experienced engineers, however, it represents a philosophy: errors are values, and handling them is just as important as the “happy path” logic.

By 2025, the ecosystem has matured significantly. We aren’t just checking for nil anymore. With the advancements introduced in Go 1.13, 1.20, and polished in recent versions, we now have powerful tools for wrapping, joining, and inspecting errors without sacrificing performance.

In this article, we will move beyond the basics. We will explore architectural patterns for error handling in large-scale applications, discuss when to use Sentinel errors versus custom types, and demonstrate how to leverage the latest standard library features to write code that is both clean and debuggable.

Prerequisites & Environment Setup
#

To follow along with the examples in this guide, ensure your environment is set up for modern Go development.

  • Go Version: Go 1.22 or higher (we will use features like errors.Join and loop variable scoping).
  • IDE: VS Code (with Go extension) or JetBrains GoLand.
  • Terminal: Any standard Unix-like shell or PowerShell.

Project Initialization
#

Let’s create a sandbox project to test these patterns.

  1. Create a directory:

    mkdir go-error-handling
    cd go-error-handling
  2. Initialize the module:

    go mod init github.com/yourname/go-error-handling

We will use the standard library for 99% of this, but we’ll also touch on structured logging.


1. The Strategy: Sentinel vs. Custom Error Types
#

In production systems, not all errors are created equal. You generally have two structural choices when defining errors in your package: Sentinel Errors and Custom Error Types. Choosing the right one impacts your API’s usability and coupling.

Comparison: When to use which?
#

Feature Sentinel Errors (var ErrX = ...) Custom Error Types (struct)
Definition Global variables defined at package level. Structs implementing the error interface.
Comparison Checked via errors.Is(err, ErrNotFound). Checked via errors.As(err, &target).
Context Static context only (fixed string). Dynamic context (User ID, File Path, Error Code).
Performance Extremely fast (pointer comparison). Slight overhead (allocation), but negligible mostly.
Use Case Database misses, connection timeouts, EOF. Validation errors, complex domain logic failures.

Implementation Example
#

Let’s look at a user package that demonstrates both strategies.

package main

import (
	"errors"
	"fmt"
	"log/slog"
	"os"
)

// --- Strategy 1: Sentinel Errors ---
// Use these for static states where no extra data is needed.
var (
	ErrUserNotFound = errors.New("user not found")
	ErrInvalidID    = errors.New("invalid user ID provided")
)

// --- Strategy 2: Custom Error Types ---
// Use these when you need to pass context (e.g., which field failed?).
type ValidationError struct {
	Field  string
	Reason string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("validation failed on field '%s': %s", e.Field, e.Reason)
}

// Simulating a database fetch
func getUser(id int) (string, error) {
	if id < 0 {
		// Return sentinel error
		return "", ErrInvalidID
	}
	if id == 0 {
		// Return custom error type
		return "", &ValidationError{Field: "id", Reason: "cannot be zero"}
	}
	if id == 99 {
		// Return sentinel error
		return "", ErrUserNotFound
	}
	return "John Doe", nil
}

func main() {
	// Initialize structured logger
	logger := slog.New(slog.NewTextHandler(os.Stdout, nil))

	ids := []int{-1, 0, 99, 42}

	for _, id := range ids {
		user, err := getUser(id)
		if err != nil {
			// Specific handling logic
			handleError(logger, id, err)
			continue
		}
		logger.Info("User fetched successfully", "id", id, "user", user)
	}
}

func handleError(logger *slog.Logger, id int, err error) {
	// Check for Sentinel Error using errors.Is
	if errors.Is(err, ErrUserNotFound) {
		logger.Warn("User missing", "id", id)
		return
	}

	// Check for Sentinel Error
	if errors.Is(err, ErrInvalidID) {
		logger.Error("Bad request", "id", id)
		return
	}

	// Check for Custom Type using errors.As
	var valErr *ValidationError
	if errors.As(err, &valErr) {
		logger.Error("Validation logic error", 
			"field", valErr.Field, 
			"reason", valErr.Reason,
		)
		return
	}

	// Catch-all
	logger.Error("Unexpected error", "err", err)
}

Key Takeaway: Use Sentinel errors for expected flow control (like io.EOF or 404s). Use Custom Types when the caller needs to extract specific data fields from the error to present to the user or logs.


2. Contextual Wrapping: The %w Verb
#

Before Go 1.13, we lost the original error type if we added context to it. Now, wrapping is the standard.

Why wrap? If a database query fails, return err passes “connection refused” up the stack. The controller receives “connection refused”. But what was refused? The user query? The auth query?

Wrapping allows us to create an error chain: Failed to create user: failed to check email availability: db connection refused

The Error Handling Decision Tree
#

Here is how you should decide how to handle an error in a layered architecture.

flowchart TD A[Error Occurs in Function] --> B{Is this the top-level handler?} B -- Yes --> C[Log Error + Return HTTP/GRPC Response] B -- No --> D{Can I handle/fix it here?} D -- Yes --> E[Handle logic e.g. Retry] E --> F[Resume Execution] D -- No --> G{Should I add context?} G -- Yes --> H[fmt.Errorf with %w] G -- No --> I[Return original error] H --> J[Return to Caller] I --> J

Code: Best Practices for Wrapping
#

package main

import (
	"errors"
	"fmt"
	"log"
)

var ErrDatabase = errors.New("database error")

func queryDatabase(query string) error {
	// Simulate a low-level failure
	return fmt.Errorf("timeout executing '%s': %w", query, ErrDatabase)
}

func createUser(username string) error {
	// Wrap the error with high-level context
	if err := queryDatabase("INSERT INTO users..."); err != nil {
		// NOTICE: We use %w to wrap the error so errors.Is still works later
		return fmt.Errorf("failed to create user '%s': %w", username, err)
	}
	return nil
}

func main() {
	err := createUser("alice")
	if err != nil {
		log.Printf("Top Level Log: %v", err)

		// Unwrap check
		if errors.Is(err, ErrDatabase) {
			log.Println(">> Action: Retrying database connection...")
		}
	}
}

Common Trap: Do not wrap errors if you want to hide implementation details from the caller (encapsulation). If your API exposes ErrDatabase, your caller is now coupled to your database logic. Sometimes, returning a generic ErrInternalServer while logging the specific error internally is better for API boundaries.


3. Aggregating Errors with errors.Join
#

Introduced in Go 1.20, errors.Join drastically simplified handling scenarios where you want to collect multiple errors (like form validation) rather than failing on the first one.

Before this, we needed third-party libraries like hashicorp/go-multierror or uber-go/multierr. Now, it is native.

Implementation: Validator Scenario
#

Imagine validating a signup request. You don’t want to tell the user “Invalid Email”, and then after they fix it, say “Invalid Password”. You want to report all issues at once.

package main

import (
	"errors"
	"fmt"
	"strings"
)

type UserRequest struct {
	Username string
	Email    string
	Age      int
}

func validateUser(u UserRequest) error {
	var errs []error

	if len(u.Username) < 3 {
		errs = append(errs, errors.New("username too short"))
	}
	if !strings.Contains(u.Email, "@") {
		errs = append(errs, errors.New("invalid email format"))
	}
	if u.Age < 18 {
		errs = append(errs, errors.New("user must be 18+"))
	}

	// Join returns nil if the slice is empty, or a wrapped error containing all of them
	return errors.Join(errs...)
}

func main() {
	req := UserRequest{
		Username: "Al",
		Email:    "invalid-email",
		Age:      16,
	}

	if err := validateUser(req); err != nil {
		fmt.Println("Validation failed:")
		fmt.Println(err)
		
		// You can still check for specific errors if needed
		// (Assuming you used specific sentinel errors in the slice)
	}
}

Output:

Validation failed:
username too short
invalid email format
user must be 18+

This simple addition reduces boilerplate significantly for batch operations or validations.


4. Advanced: Defer and Named Return Values
#

A subtle bug often occurs when handling errors in defer. For example, closing a file or committing a transaction. If file.Close() fails, but the main function returns nil, you might assume data was written safely when it wasn’t (e.g., disk full on flush).

To handle this, we use Named Return Values.

package main

import (
	"fmt"
	"os"
)

// writeToFile demonstrates how to capture errors from defer
func writeToFile(filename, content string) (err error) {
	f, err := os.Create(filename)
	if err != nil {
		return err
	}
	
	// Defer a closure that can assign to the named return value 'err'
	defer func() {
		closeErr := f.Close()
		if closeErr != nil {
			if err == nil {
				// If the function was successful so far, the close error becomes THE error
				err = closeErr
			} else {
				// If we already had an error, we join them (Go 1.20+)
				// so we don't lose the original write error
				err = fmt.Errorf("write error: %v, close error: %v", err, closeErr)
			}
		}
	}()

	_, err = f.WriteString(content)
	return err // This value might be updated by the deferred function
}

func main() {
	if err := writeToFile("test.txt", "Hello World"); err != nil {
		fmt.Printf("Operation failed: %v\n", err)
	} else {
		fmt.Println("Operation successful")
	}
}

This pattern is critical for database transactions. If a Commit() is deferred and fails, you must ensure the function returns that error.


5. Performance and Pitfalls
#

The Interface Nil Trap
#

This is the most famous Go “gotcha.” An interface holding a nil concrete pointer is not nil.

func returnsPointer() *MyError {
    return nil // Returns a nil pointer
}

func returnsInterface() error {
    var p *MyError = nil
    return p // Returns an interface containing (type=*MyError, value=nil)
}

func main() {
    err := returnsInterface()
    if err != nil {
        // This block WILL execute!
        fmt.Println("Trap triggered: Error is not nil")
    }
}

Solution: Always return nil explicitly when there is no error. Never return a nil pointer of a concrete custom error type.

Allocation Overhead
#

fmt.Errorf with %w causes an allocation. In hot loops (e.g., processing millions of stream packets), constantly wrapping errors can generate GC pressure.

  • Best Practice: For hot paths, define static sentinel errors and return them directly. Only wrap errors at the boundaries of your domain logic, not in the tight loops.

Conclusion
#

Error handling in Go 2025 is robust, explicit, and tool-rich. By moving away from simple string checks and embracing the type system, we create applications that are easier to debug and maintain.

Summary of Best Practices:

  1. Errors are Values: Treat them as part of your domain model.
  2. Wrap with Context: Use fmt.Errorf("%w") to add “breadcrumbs” to the failure.
  3. Check with Is/As: Never check error strings (err.Error() == "..."). Use errors.Is for sentinels and errors.As for types.
  4. Use errors.Join: Perfect for validations and parallel tasks where multiple things might fail.
  5. Log Responsibly: Log errors only at the top level or where they are handled (swallowed). Avoid “log and return” which creates duplicate logs.

Robust error handling is what separates “scripts” from “systems.” Start implementing these patterns today to make your Go services production-hardened.

Further Reading
#