Youssef Ameachaq's Blog

Youssef Ameachaq

Handling errors in Go CLI tools


Handling errors in Go CLI tools (or any Go application) is an essential aspect of writing robust and user-friendly software. It’s common to feel overwhelmed by frequent if err != nil checks, but this is a key part of Go’s explicit error handling philosophy. Below, I’ll explain best practices and patterns for handling errors in CLI development, including when to use panic, how to handle errors gracefully, and some tips to reduce repetitive code.


1. Use panic Sparingly

In CLI tools, avoid using panic unless absolutely necessary. panic is typically used for critical, unrecoverable errors, like programming bugs or issues that make further execution impossible (e.g., corrupted internal states or assumptions being violated).

When to use panic:

For example:

func mustOpenFile(filename string) *os.File {
    f, err := os.Open(filename)
    if err != nil {
        panic(fmt.Sprintf("failed to open file: %s", err))
    }
    return f
}

However, for production CLI tools, prefer graceful error messages to ensure the user sees actionable feedback instead of a stack trace.


2. Show Meaningful Error Messages

For user-facing errors, always return clear, actionable messages instead of panic. Use fmt.Errorf to create custom error messages that describe what went wrong.

Example:

if err := doSomething(); err != nil {
    fmt.Fprintf(os.Stderr, "Error: %v\n", err)
    os.Exit(1) // Exit with a non-zero status to indicate failure
}

3. Common Patterns for Reducing if err != nil Boilerplate

Pattern 1: Wrap Errors with Context

Use the errors or fmt package to add context to errors for better debugging. This reduces repetitive if blocks and makes errors more informative.

import (
    "fmt"
    "os"
)

func main() {
    err := doSomething()
    if err != nil {
        fmt.Fprintf(os.Stderr, "Failed to complete the task: %v\n", err)
        os.Exit(1)
    }
}

func doSomething() error {
    _, err := os.Open("nonexistent-file.txt")
    if err != nil {
        return fmt.Errorf("unable to open file: %w", err)
    }
    return nil
}

In this example:

Pattern 2: Helper Functions for Error Handling

Abstract repeated error-handling patterns into helper functions.

Example: Logging and exiting on errors:

func handleError(err error, message string) {
    if err != nil {
        fmt.Fprintf(os.Stderr, "%s: %v\n", message, err)
        os.Exit(1)
    }
}

Usage:

f, err := os.Open("config.json")
handleError(err, "Failed to open configuration file")
defer f.Close()

Pattern 3: Use Named Error Handling Functions

For certain repeated operations, you can define specific functions to reduce repetitive if checks.

Example:

func checkFileExists(filename string) error {
    if _, err := os.Stat(filename); os.IsNotExist(err) {
        return fmt.Errorf("file %s does not exist", filename)
    }
    return nil
}

func main() {
    err := checkFileExists("important.txt")
    if err != nil {
        fmt.Println("Error:", err)
        os.Exit(1)
    }
    fmt.Println("File exists!")
}

4. Use Error-Wrapping Libraries

In Go 1.13+, you can use errors with the %w verb for error wrapping, which allows easy inspection of underlying errors. For example:

import (
    "errors"
    "fmt"
    "os"
)

func readFile(path string) error {
    _, err := os.Open(path)
    if err != nil {
        return fmt.Errorf("failed to open file %s: %w", path, err)
    }
    return nil
}

func main() {
    err := readFile("nonexistent.txt")
    if errors.Is(err, os.ErrNotExist) {
        fmt.Println("File does not exist. Please check the path.")
    } else if err != nil {
        fmt.Println("An error occurred:", err)
    }
}

This provides:


5. Guidelines for CLI Error Handling