Please purchase the course to watch this video.

Full Course
Effective error handling in Go is crucial for building robust applications, and the standard approach typically involves logging errors to the console or exiting with a non-zero status code, often using simple conditional checks. For most cases, this direct approach suffices, but more sophisticated scenarios call for advanced error handling techniques like error wrapping and custom error types. By leveraging Go’s errors
package, developers can add context to errors through wrapping, enabling precise identification of where failures occur, and use functions like errors.Is
for reliable error comparison even with wrapped errors. Furthermore, custom error types allow developers to attach meaningful data to errors and use errors.As
to extract this information for more nuanced control flow. Combining these practices with error joining enables the aggregation and inspection of multiple errors, facilitating batch processing while maintaining clarity on specific failure reasons. Mastery of these error handling strategies not only streamlines debugging but also empowers Go applications to respond flexibly to varied failure conditions.
No links available for this lesson.
So far, when it comes to error handling in Go, we've taken a look at two main approaches you can take:
- Writing the error to the console - specifically the standard error stream
- Exit the application - using a non-zero status code (e.g.,
os.Exit(1)
)
Typically, we've combined this error handling by making use of log.Fatal()
, which does both printing to standard error as well as exiting with a non-zero status code:
if err != nil {
log.Fatalf("An error occurred: %v", err)
}
For about 90% of the errors when it comes to Go, this is going to be the correct approach. Either:
- Logging to the console that an error took place, or
- Propagating the error up to the caller for them to handle
Example: Configuration Loading
Let's look at an example using our config package from the previous lesson:
// config/config.go
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
func LoadConfig(filePath string) (Config, error) {
var config Config
// Potential error when opening file
file, err := os.Open(filePath)
if err != nil {
return config, fmt.Errorf("open: %w", err)
}
defer file.Close()
// Potential error when decoding YAML
decoder := yaml.NewDecoder(file)
if err := decoder.Decode(&config); err != nil {
return config, fmt.Errorf("decode: %w", err)
}
return config, nil
}
Testing Basic Error Handling
// main.go
package main
import (
"log"
"os"
"your-project/config"
)
func main() {
if len(os.Args) < 2 {
log.Fatal("Please provide a config file path")
}
cfg, err := config.LoadConfig(os.Args[1])
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
fmt.Printf("Config loaded: %+v\n", cfg)
}
Testing different scenarios:
# Valid config file
go run main.go config.yaml
# Output: Config loaded: {DatabaseURL:... RateLimit:...}
# Non-existent file
go run main.go noexist.yaml
# Output: Failed to load config: open: open noexist.yaml: no such file or directory
# Permission denied file
sudo touch cfg.yaml
sudo chmod 600 cfg.yaml # Only root can read
go run main.go cfg.yaml
# Output: Failed to load config: open: open cfg.yaml: permission denied
When Basic Error Handling Isn't Enough
However, there are situations where you may want to either:
- Provide additional context to an error
- Handle an error in a specific way
Example Scenario: Default Configuration Fallback
Let's say if the file doesn't exist, we want to prompt the user to see if they want to use a default configuration instead.
OS Package Error Variables
If we take a look at the os
package, specifically the variables tab, you can see that there are a number of different error variables that have been defined:
os.ErrInvalid
- Invalid argumentos.ErrPermission
- Permission denied (we saw this with the cfg.yaml file)os.ErrClosed
- File has been closed alreadyos.ErrExist
- File already exists (typically when using exclusive creation)os.ErrNotExist
- File does not exist (this is what we want!)
The Problem with Error Wrapping
Let's try a naive approach first to see why it doesn't work:
func main() {
cfg, err := config.LoadConfig(os.Args[1])
// This WON'T work as expected!
if err == os.ErrNotExist {
// Handle file not found...
fmt.Println("File not found - using default config")
} else if err != nil {
log.Fatalf("Error occurred: %v", err)
}
fmt.Printf("Config: %+v\n", cfg)
}
Why doesn't this work?
The issue is error wrapping. In our config.LoadConfig()
function, we wrap errors with additional context:
return config, fmt.Errorf("open: %w", err)
This means the error we receive is not directly os.ErrNotExist
, but rather a wrapped error that looks like:
"open: open noexist.yaml: no such file or directory"
The os.ErrNotExist
is buried inside this wrapped error chain, so direct comparison with ==
fails.
Solution: Using errors.Is()
Go provides the errors.Is()
function to check errors in the entire error chain:
package main
import (
"errors"
"fmt"
"log"
"os"
"your-project/config"
)
func main() {
cfg, err := config.LoadConfig(os.Args[1])
// Use errors.Is() to check the error chain
if errors.Is(err, os.ErrNotExist) {
// Handle file not found
useDefault, defaultCfg := config.PromptToUseDefault()
if !useDefault {
log.Fatal("User chose not to use default config")
}
cfg = defaultCfg
fmt.Println("Using default configuration")
} else if err != nil {
log.Fatalf("Error occurred: %v", err)
}
fmt.Printf("Config: %+v\n", cfg)
}
Adding the Prompt Function
Let's add the prompt functionality to our config package:
// config/config.go
import (
"bufio"
"fmt"
"os"
"strings"
)
// PromptToUseDefault asks the user if they want to use default config
func PromptToUseDefault() (bool, Config) {
fmt.Print("Use default config? (y/N): ")
scanner := bufio.NewScanner(os.Stdin)
scanner.Scan()
answer := strings.ToLower(strings.TrimSpace(scanner.Text()))
if answer == "y" || answer == "yes" {
return true, Default()
}
return false, Config{}
}
// Default returns a default configuration
func Default() Config {
return Config{
DatabaseURL: "postgresql://localhost:5432/defaultdb",
RateLimit: RateLimitConfig{
Limit: 100,
Duration: time.Minute,
},
BlockedIPs: []string{},
Port: 8080,
}
}
Testing the Enhanced Error Handling
go run main.go noexist.yaml
# Output: Use default config? (y/N): y
# Using default configuration
# Config: {DatabaseURL:postgresql://localhost:5432/defaultdb ...}
Custom Error Variables
We can also provide our own custom error values to package consumers:
// config/config.go
import "errors"
// Package-level error variables (use ERR prefix by convention)
var (
ErrInvalidDatabaseURL = errors.New("database URL invalid")
ErrInvalidPort = errors.New("invalid port value")
)
func LoadConfig(filePath string) (Config, error) {
var config Config
// ... existing file loading code ...
// Custom validation
if config.DatabaseURL == "" {
return config, ErrInvalidDatabaseURL
}
if config.Port < 80 || config.Port > 9090 {
return config, ErrInvalidPort
}
return config, nil
}
Using Custom Error Variables
func main() {
cfg, err := config.LoadConfig(os.Args[1])
if errors.Is(err, os.ErrNotExist) {
// Handle file not found...
} else if errors.Is(err, config.ErrInvalidPort) {
fmt.Println("Invalid port detected - using default config")
cfg = config.Default()
} else if errors.Is(err, config.ErrInvalidDatabaseURL) {
fmt.Println("Invalid database URL - using default config")
cfg = config.Default()
} else if err != nil {
log.Fatalf("Error occurred: %v", err)
}
fmt.Printf("Config: %+v\n", cfg)
}
Limitation of Simple Error Variables
While error variables work well for simple cases, they have limitations when you need additional context. For example, with ErrInvalidPort
, we might want to know what the actual invalid port value was.
Problematic Approach: Error Wrapping
// This approach works but isn't ideal
if config.Port < 80 || config.Port > 9090 {
return config, fmt.Errorf("port value is %d: %w", config.Port, ErrInvalidPort)
}
Problems with this approach:
- The port value is embedded in the string
- You can't easily extract the port value for programmatic use
- Parsing error messages is fragile and error-prone
Better Solution: Custom Error Types
Instead of simple error variables, we can create custom error types that carry additional data:
// config/config.go
// Custom error type that implements the error interface
type InvalidPortError struct {
Port int
}
// Implement the error interface
func (e InvalidPortError) Error() string {
return fmt.Sprintf("invalid port value: %d", e.Port)
}
Using Custom Error Types
func LoadConfig(filePath string) (Config, error) {
// ... existing code ...
if config.Port < 80 || config.Port > 9090 {
return config, InvalidPortError{Port: config.Port}
}
return config, nil
}
Using errors.As()
for Custom Error Types
To check for custom error types and extract their data, we use errors.As()
:
func main() {
cfg, err := config.LoadConfig(os.Args[1])
if errors.Is(err, os.ErrNotExist) {
// Handle file not found...
} else if var portErr config.InvalidPortError; errors.As(err, &portErr) {
// We can now access the actual port value!
fmt.Printf("Invalid port %d detected - using default config\n", portErr.Port)
cfg = config.Default()
} else if err != nil {
log.Fatalf("Error occurred: %v", err)
}
fmt.Printf("Config: %+v\n", cfg)
}
How errors.As()
Works
errors.As()
finds the first error in the error chain that matches the target type, and if one is found:
- Sets the target to that error value
- Returns
true
This allows us to:
- Check the error type (like
errors.Is()
but for types instead of values) - Extract the error data (access the
Port
field in our example)
Testing Custom Error Types
# config.yaml with invalid port
database_url: "postgresql://user:pass@localhost:5432/app"
port: 9090 # This will trigger InvalidPortError
rate_limit:
limit: 10
duration: "1m"
blocked_ips: []
go run main.go config.yaml
# Output: Invalid port 9090 detected - using default config
# Config: {DatabaseURL:postgresql://localhost:5432/defaultdb Port:8080 ...}
Multiple Error Types Example
You can define multiple custom error types for different validation scenarios:
// config/config.go
type InvalidPortError struct {
Port int
}
func (e InvalidPortError) Error() string {
return fmt.Sprintf("invalid port value: %d", e.Port)
}
type InvalidDatabaseURLError struct {
URL string
Reason string
}
func (e InvalidDatabaseURLError) Error() string {
return fmt.Sprintf("invalid database URL '%s': %s", e.URL, e.Reason)
}
func LoadConfig(filePath string) (Config, error) {
// ... file loading code ...
// Validate database URL
if config.DatabaseURL == "" {
return config, InvalidDatabaseURLError{
URL: config.DatabaseURL,
Reason: "URL cannot be empty",
}
}
if !strings.HasPrefix(config.DatabaseURL, "postgresql://") {
return config, InvalidDatabaseURLError{
URL: config.DatabaseURL,
Reason: "must start with postgresql://",
}
}
// Validate port
if config.Port < 80 || config.Port > 9090 {
return config, InvalidPortError{Port: config.Port}
}
return config, nil
}
Advanced: Joining Multiple Errors
Go also provides errors.Join()
for handling multiple errors at once:
// Example: Loading multiple config files
func LoadConfigs(filePaths ...string) ([]Config, error) {
var configs []Config
var errs []error
for _, path := range filePaths {
cfg, err := LoadConfig(path)
if err != nil {
errs = append(errs, fmt.Errorf("loading %s: %w", path, err))
continue
}
configs = append(configs, cfg)
}
if len(errs) > 0 {
return configs, errors.Join(errs...)
}
return configs, nil
}
You can still use errors.Is()
on the joined error to check for specific error types within the collection.
Summary
The errors
package provides powerful tools for advanced error handling:
Key Functions:
-
errors.Is(err, target)
- Checks if any error in the chain matches the target error value
- Use for simple error variables like
os.ErrNotExist
-
errors.As(err, &target)
- Finds the first error in the chain that matches the target type
- Sets target to that error and returns true if found
- Use for custom error types to extract additional data
-
errors.Join(errs...)
- Combines multiple errors into a single error
- Useful for batch operations that can have multiple failures
When to Use Advanced Error Handling:
- 90% of the time: Use simple error checking with
log.Fatal()
or propagate up - Custom error variables: When you need to handle specific error conditions differently
- Custom error types: When you need to carry additional context/data with errors
- Error wrapping: Always wrap errors when propagating up to provide context
Best Practices:
- Always wrap errors when propagating:
fmt.Errorf("operation failed: %w", err)
- Use error variables for simple, well-defined error conditions
- Use error types when you need to carry additional data
- Prefer
errors.Is()
anderrors.As()
over string comparison or type assertions - Export error variables/types that your package users might want to handle specifically
Most of the time when it comes to error handling, you're going to be using the simple form. However, at some point you're going to want to be able to have custom error logic in your code, and using this package is the way to do so.
Default Configuration Implementation
Implement a default configuration system that returns a sensible default configuration when no config file is provided.
Requirements:
- Add a
default()
function to the config package that returns a default configuration - Use the
flag.Visit()
function to determine whether the--config
flag was actually set by the user - Return the default configuration when no config flag is provided
- Return an error when the config flag is provided but points to an invalid/missing file
- Ensure the default configuration has reasonable values for all required fields
Key distinction: Empty flag value (--config=""
) should return an error, while no flag at all should return defaults.
Environment Variable Override System
Implement environment variable support that can override configuration values from both config files and default configuration.
Requirements:
- Allow environment variables to override any configuration value
- Support nested configuration structures (e.g.,
RATE_LIMIT_DURATION
forrate_limit.duration
) - Environment variables should take precedence over both config file values and defaults
- Handle different data types properly (strings, integers, durations, slices)
- Use a consistent naming convention for environment variables (e.g.,
DATABASE_URL
,RATE_LIMIT_LIMIT
)
Example: If DATABASE_URL=foobar
is set as an environment variable, it should override the database URL from the config file.
Precedence order: Environment Variables > Config File > Defaults