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:
- The request times out.
- 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-Afterheader value in seconds).
Which error strategy (sentinel or custom type) would you use for each case, and why?
Show answer
-
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 useerrors.Is(err, ErrRequestTimeout)to decide whether to retry immediately. -
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.Asto extract this error, get theRetryAftervalue, 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 tomain, which then prints a user-friendly message tostderrand 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:
- Create a new file named
parser.go. - Inside
parser.go, define a sentinel errorErrSyntaxand a custom error typeMissingKeyErrorthat 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) } - Write a function
parse(config string) error. It should:- Return
ErrSyntaxif the config string doesn't contain a colon:. (Usestrings.Contains). - Return a
*MissingKeyErrorwithKey: "host"if the config doesn't contain the substring"host:". - Return
nilif both checks pass.
- Return
- Write a second function
loadAndParse(path string) errorthat:- Calls
parse("host:localhost"). (We'll simulate reading from a file). - If
parsereturns an error,loadAndParseshould wrap it with the contextfmt.Errorf("failed to process config %s: %w", path, err).
- Calls
- In your
mainfunction:- Call
loadAndParse("config.toml")and store the returned error. - Use
if/else ifwitherrors.Isanderrors.Asto check forErrSyntaxandMissingKeyError. - 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.
- Call
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. Useerrors.Isfor sentinel errors anderrors.Asfor 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
mainfunction 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.