Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 8

Idiomatic Error Handling in Go

Recommended resources

Hello! Welcome to the final lesson in our "Go Fundamentals" module.

In our last few lessons, you've seen the (value, error) return pattern repeatedly. You learned how defer helps you write clean, reliable code in the face of potential failures. Now, it's time to zoom in on the error part of that pair.

So far, we've mostly treated errors as a simple signal to stop: if err != nil. But for building robust DevOps tools, this isn't enough. You need to know why something failed so you can retry, provide a helpful message, or log detailed context for debugging.

This lesson will teach you how to handle errors idiomatically in Go. You'll learn to create, inspect, and add context to errors, transforming them from simple failure signals into rich, informative values that make your applications more resilient and easier to maintain.

Errors as Values: The Core Philosophy

Go's approach to error handling is fundamentally different from the try/catch exceptions you might have seen in Python. Go treats errors as regular values that are returned by functions. This simple design choice has a profound impact: it forces you to confront and handle potential failures explicitly as part of your normal control flow.

The key principle is that a function's job is to do its task and report the outcome. If it fails, it should package the problem into an error value and return it to the caller. The caller, which has more context, then decides what to do: log the error, retry the operation, or terminate the program.

Don't Just Handle Errors, Return Them: A Guide to Idiomatic Go

This article explains the philosophy of "Errors are Values" and contrasts the idiomatic Go approach with a common anti-pattern.

Read the sections 'The Golden Rule: Errors Are Values', 'The Anti-Pattern: Logging Inside a Function', and 'The Idiomatic Way: Return the Error'. Focus on why logging an error inside a low-level function is a bad idea and how returning it makes the function more reusable.

This idea of returning errors empowers the caller. A CLI tool might print the error to the console, while an HTTP service might log it and return a 500 status code. The low-level function doesn't need to know about any of that; it just needs to report its failure clearly.


The Error Handling Toolkit

Now that we've covered the "why," let's explore the "how." Go's standard library provides a powerful set of tools for creating and inspecting errors.

To get a comprehensive overview, we'll watch a video that walks through the main techniques, from the simplest errors to more advanced patterns.

The secret to making Golang error handling a breeze

This video from Earthly provides an excellent, practical walkthrough of Go's error handling features. It covers everything from creating simple errors to wrapping them for added context.

Watch the video from 00:25 to 11:29. It will introduce several key concepts that we will break down next: Simple Errors: Using errors.New and fmt.Errorf. Sentinel Errors: Pre-defined error variables for specific cases. Custom Error Types: Structs that carry extra data with an error. Error Wrapping: Adding context to an error as it passes up the call stack.

Let's break down the techniques from that video into a clear reference.

1. Simple Errors: errors.New and fmt.Errorf

This is the most basic form of error. The errors package provides a simple way to create an error with a static message.

import "errors"

err := errors.New("something went wrong")

If you need to include dynamic information, like a variable's value, the fmt package is your tool of choice.

import "fmt"

filename := "config.yaml"
err := fmt.Errorf("file not found: %s", filename)

2. Sentinel Errors and errors.Is

Sometimes, a string message isn't enough. The calling code might need to check for a specific, known error to handle it differently. A sentinel error is a public, pre-defined error variable used for this purpose. The standard library uses this pattern frequently (e.g., io.EOF, os.ErrNotExist).

// In a package, you might define:
var ErrInvalidConfig = errors.New("invalid configuration")

// In a function:
func loadConfig() error {
    if /* config is bad */ {
        return ErrInvalidConfig
    }
    return nil
}

// The caller can then check for this specific error:
err := loadConfig()
if errors.Is(err, ErrInvalidConfig) {
    fmt.Println("Configuration is invalid. Please fix it.")
} else if err != nil {
    fmt.Printf("An unexpected error occurred: %v\n", err)
}

We use errors.Is(err, target) instead of err == target because errors.Is can find the target error even if it has been "wrapped" (more on that soon).

3. Custom Error Types and errors.As

A sentinel error tells you what happened, but it can't carry extra data. What if you need to report which configuration key was invalid, or an HTTP status code you received? For this, you use a custom error type.

A custom error is simply a struct that implements the error interface (which just means it has an Error() string method).

// 1. Define the custom error struct
type ConfigValidationError struct {
    Field  string
    Reason string
}

// 2. Implement the error interface
func (e *ConfigValidationError) Error() string {
    return fmt.Sprintf("validation failed for field '%s': %s", e.Field, e.Reason)
}

// 3. Return it from a function
func validate(port int) error {
    if port <= 0 {
        return &ConfigValidationError{
            Field:  "port",
            Reason: "must be a positive number",
        }
    }
    return nil
}

// 4. The caller can inspect it with errors.As
err := validate(-80)
var validationErr *ConfigValidationError
if errors.As(err, &validationErr) {
    // We know it's a validation error, and we have the data!
    fmt.Printf("Please fix the '%s' field. Reason: %s\n", validationErr.Field, validationErr.Reason)
}

4. Error Wrapping and %w

This is one of the most powerful features for debugging. When a low-level function returns an error, you often want to add context before passing it up the call stack. For example, knowing connection refused is useful, but knowing failed to connect to database to fetch user profile: connection refused is much better.

This is called wrapping an error. You use the %w verb in fmt.Errorf.

func connectToDB() error {
    // This might return a low-level network error
    return errors.New("connection timed out") 
}

func fetchUser(userID int) error {
    err := connectToDB()
    if err != nil {
        // Wrap the original error with more context
        return fmt.Errorf("failed to fetch user %d: %w", userID, err)
    }
    return nil
}

When you call fetchUser(123), the error message will be failed to fetch user 123: connection timed out.

The magic is that errors.Is and errors.As can "unwrap" the error chain to find the original cause. Even with the wrapped error from fetchUser, errors.Is could still find the original "connection timed out" error if it were a sentinel.

Test your understanding!

You are writing a function to make an API call. It can fail in two specific ways:

  1. The request times out.
  2. The API returns a 429 "Too Many Requests" status code, and you want to know how long you should wait before retrying (the API provides a Retry-After header value in seconds).

Which error strategy (sentinel or custom type) would you use for each case, and why?

Show answer
  1. Timeout: A sentinel error like var ErrRequestTimeout = errors.New("request timed out") is a good fit. It's a specific, known condition that doesn't need to carry extra data. The caller can use errors.Is(err, ErrRequestTimeout) to decide whether to retry immediately.

  2. Rate Limiting (429): A custom error type is the best choice here. You need to carry extra data—the retry delay.

    type RateLimitError struct {
        RetryAfter int // Delay in seconds
    }
    
    func (e *RateLimitError) Error() string {
        return fmt.Sprintf("rate limit exceeded, try again in %d seconds", e.RetryAfter)
    }
    

    The caller can then use errors.As to extract this error, get the RetryAfter value, and wait for the specified duration before making another attempt.


The Payoff: Handling Errors at the Boundary

If every function returns its errors, where does it stop? You handle the error at the boundary of a logical unit of work. For the tools you'll be building, these are the most common boundaries:

  • In a CLI Tool's main() function: This is the ultimate boundary. Your core application logic returns an error to main, which then prints a user-friendly message to stderr and exits with a non-zero status code.
  • In an HTTP Handler: The handler's job is to process one request. It calls your business logic, and if it gets an error back, it logs the full, detailed error (with all the wrapped context!) for debugging and returns a simple, safe error message to the client (e.g., HTTP 500).

Don't Just Handle Errors, Return Them: A Guide to Idiomatic Go

This article explains the concept of handling errors at logical 'boundaries' and provides clear, practical examples for CLI tools and HTTP handlers.

Read the section 'Knowing When to Stop: Handling Errors at the Boundary'. The code examples for an HTTP handler and a CLI tool's main function are perfect illustrations of this principle.


Hands-On Practice: A Robust Parser

Let's put all these concepts together. You'll write a small utility that simulates parsing a configuration string.

Your Task:

  1. Create a new file named parser.go.
  2. Inside parser.go, define a sentinel error ErrSyntax and a custom error type MissingKeyError that holds the name of the missing key.
    var ErrSyntax = errors.New("invalid syntax")
    
    type MissingKeyError struct {
        Key string
    }
    
    func (e *MissingKeyError) Error() string {
        return fmt.Sprintf("missing required key: '%s'", e.Key)
    }
    
  3. Write a function parse(config string) error. It should:
    • Return ErrSyntax if the config string doesn't contain a colon :. (Use strings.Contains).
    • Return a *MissingKeyError with Key: "host" if the config doesn't contain the substring "host:".
    • Return nil if both checks pass.
  4. Write a second function loadAndParse(path string) error that:
    • Calls parse("host:localhost"). (We'll simulate reading from a file).
    • If parse returns an error, loadAndParse should wrap it with the context fmt.Errorf("failed to process config %s: %w", path, err).
  5. In your main function:
    • Call loadAndParse("config.toml") and store the returned error.
    • Use if/else if with errors.Is and errors.As to check for ErrSyntax and MissingKeyError.
    • Print a specific, user-friendly message for each case.
    • For MissingKeyError, print the name of the missing key.
    • If it's any other error, print the full error message, which will show the wrapped context.

Example Code Structure:

Click to see the solution structure
package main

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

// Sentinel error
var ErrSyntax = errors.New("invalid syntax")

// Custom error type
type MissingKeyError struct {
	Key string
}

func (e *MissingKeyError) Error() string {
	return fmt.Sprintf("missing required key: '%s'", e.Key)
}

// Simulates parsing a config
func parse(config string) error {
	if !strings.Contains(config, ":") {
		return ErrSyntax
	}
	if !strings.Contains(config, "host:") {
		return &MissingKeyError{Key: "host"}
	}
	// Add more checks here...
	return nil
}

// Wraps the error from parse with more context
func loadAndParse(path string, content string) error {
	err := parse(content)
	if err != nil {
		return fmt.Errorf("failed to process config %s: %w", path, err)
	}
	return nil
}

func main() {
    // --- Test Case 1: Syntax Error ---
	fmt.Println("--- Test Case 1: Syntax Error ---")
	err1 := loadAndParse("bad.toml", "no colon here")
	if err1 != nil {
		handleError(err1)
	}
	
    // --- Test Case 2: Missing Key Error ---
	fmt.Println("\n--- Test Case 2: Missing Key Error ---")
	err2 := loadAndParse("incomplete.toml", "port: 8080")
	if err2 != nil {
		handleError(err2)
	}

    // --- Test Case 3: Success ---
	fmt.Println("\n--- Test Case 3: Success ---")
    err3 := loadAndParse("good.toml", "host: localhost")
    if err3 != nil {
		handleError(err3)
	} else {
        fmt.Println("Config parsed successfully!")
    }
}

func handleError(err error) {
	var missingKey *MissingKeyError
	if errors.Is(err, ErrSyntax) {
		fmt.Println("Error: Configuration file has invalid syntax.")
		fmt.Printf("Details: %v\n", err)
	} else if errors.As(err, &missingKey) {
		fmt.Printf("Error: Configuration is missing the '%s' key.\n", missingKey.Key)
		fmt.Printf("Details: %v\n", err)
	} else {
		fmt.Printf("An unexpected error occurred: %v\n", err)
	}
}

Conclusion

This lesson completes our journey through the fundamentals of Go. Mastering idiomatic error handling is a significant step toward writing professional, production-ready Go code. It makes your tools more robust, easier to debug, and simpler to maintain.

Key Takeaways:

  • Errors are Values: Return them from functions and let the caller decide how to handle them. Avoid side-effects like logging in low-level functions.
  • Inspect, Don't Just Check: Move beyond if err != nil. Use errors.Is for sentinel errors and errors.As for custom error types to programmatically respond to failures.
  • Context is King: Use error wrapping (%w) to build a chain of context as errors bubble up. This is invaluable for debugging.
  • Handle at the Boundary: Stop propagating errors and handle them at the edge of a logical unit of work, like your main function or an HTTP handler.

Next Up

You've now built a solid foundation in Go. You understand variables, control flow, functions, and robust error handling. In the next module, Data Structures in Go, we will explore how Go manages collections of data. We'll start with slices, Go's powerful and flexible approach to dynamic arrays, which you'll use constantly when building tools.