In Go Tutorial #4, you learned the basics of error handling — the error interface and the if err != nil pattern. Now it is time to go deeper.
Real applications need more than basic error checks. You need to know what went wrong, where it went wrong, and how to handle different errors differently. Go gives you tools for all of this.
Error Wrapping
When a function calls another function and gets an error, you should add context before returning it. Use fmt.Errorf with the %w verb to wrap errors:
package main
import (
"fmt"
"os"
)
func readConfig(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
// Wrap the error with context
return nil, fmt.Errorf("readConfig(%s): %w", path, err)
}
return data, nil
}
func loadApp() error {
_, err := readConfig("/etc/app/config.json")
if err != nil {
// Wrap again with more context
return fmt.Errorf("loadApp: %w", err)
}
return nil
}
func main() {
err := loadApp()
if err != nil {
fmt.Println("Error:", err)
}
}
Output:
Error: loadApp: readConfig(/etc/app/config.json): open /etc/app/config.json: no such file or directory
The error message reads like a stack trace: loadApp called readConfig, which tried to open a file. Each layer adds context. The original error is preserved inside.
Important: Use %w (not %v) to wrap errors. Only %w preserves the error chain for errors.Is() and errors.As().
errors.Is — Checking Error Types
errors.Is checks if an error (or any error in its chain) matches a specific error value:
package main
import (
"errors"
"fmt"
"os"
)
func readFile(path string) error {
_, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read failed: %w", err)
}
return nil
}
func main() {
err := readFile("/nonexistent/file.txt")
// Check if the error is (or wraps) os.ErrNotExist
if errors.Is(err, os.ErrNotExist) {
fmt.Println("File does not exist")
} else if err != nil {
fmt.Println("Other error:", err)
}
// errors.Is walks the entire error chain
fmt.Println("\nFull error:", err)
}
Output:
File does not exist
Full error: read failed: open /nonexistent/file.txt: no such file or directory
Even though we wrapped the error, errors.Is still finds os.ErrNotExist inside the chain.
Do not use == to compare errors. Always use errors.Is:
// Bad — does not check wrapped errors
if err == os.ErrNotExist { ... }
// Good — checks the entire error chain
if errors.Is(err, os.ErrNotExist) { ... }
errors.As — Extracting Error Details
errors.As extracts a specific error type from the chain. This gives you access to the error’s fields:
package main
import (
"errors"
"fmt"
"net"
)
func connectToServer(addr string) error {
_, err := net.Dial("tcp", addr)
if err != nil {
return fmt.Errorf("connection failed: %w", err)
}
return nil
}
func main() {
err := connectToServer("192.0.2.1:9999")
if err != nil {
// Try to extract a *net.OpError from the chain
var opErr *net.OpError
if errors.As(err, &opErr) {
fmt.Println("Operation:", opErr.Op)
fmt.Println("Network:", opErr.Net)
fmt.Println("Address:", opErr.Addr)
fmt.Println()
}
fmt.Println("Full error:", err)
}
}
Output:
Operation: dial
Network: tcp
Address: 192.0.2.1:9999
Full error: connection failed: dial tcp 192.0.2.1:9999: connect: connection refused
errors.As finds the *net.OpError inside the wrapped error chain and fills in our variable. Now we can access its fields.
Sentinel Errors
A sentinel error is a predefined error value that callers can check for. Define them as package-level variables:
package main
import (
"errors"
"fmt"
)
// Sentinel errors — package-level variables
var (
ErrNotFound = errors.New("not found")
ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden")
)
type UserStore struct {
users map[int]string
}
func NewUserStore() *UserStore {
return &UserStore{
users: map[int]string{
1: "Alex",
2: "Sam",
3: "Jordan",
},
}
}
func (s *UserStore) GetUser(id int) (string, error) {
name, ok := s.users[id]
if !ok {
return "", fmt.Errorf("GetUser(id=%d): %w", id, ErrNotFound)
}
return name, nil
}
func (s *UserStore) DeleteUser(id int, isAdmin bool) error {
if !isAdmin {
return fmt.Errorf("DeleteUser: %w", ErrUnauthorized)
}
_, ok := s.users[id]
if !ok {
return fmt.Errorf("DeleteUser(id=%d): %w", id, ErrNotFound)
}
delete(s.users, id)
return nil
}
func main() {
store := NewUserStore()
// Test GetUser
_, err := store.GetUser(99)
if errors.Is(err, ErrNotFound) {
fmt.Println("User not found (expected)")
}
// Test DeleteUser without admin
err = store.DeleteUser(1, false)
if errors.Is(err, ErrUnauthorized) {
fmt.Println("Not authorized (expected)")
}
// Test successful delete
err = store.DeleteUser(1, true)
if err == nil {
fmt.Println("User deleted successfully")
}
}
Output:
User not found (expected)
Not authorized (expected)
User deleted successfully
Sentinel errors give callers a clear way to handle different error cases. The naming convention is Err + the condition: ErrNotFound, ErrTimeout, ErrInvalid.
Custom Error Types
For errors that carry extra information, create a custom type that implements the error interface:
package main
import (
"errors"
"fmt"
)
// Custom error type
type ValidationError struct {
Field string
Message string
}
// Implement the error interface
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error: %s — %s", e.Field, e.Message)
}
type APIError struct {
Code int
Message string
Err error
}
func (e *APIError) Error() string {
if e.Err != nil {
return fmt.Sprintf("API error %d: %s: %v", e.Code, e.Message, e.Err)
}
return fmt.Sprintf("API error %d: %s", e.Code, e.Message)
}
// Unwrap enables errors.Is and errors.As to walk the chain
func (e *APIError) Unwrap() error {
return e.Err
}
func validateAge(age int) error {
if age < 0 {
return &ValidationError{Field: "age", Message: "must be positive"}
}
if age > 150 {
return &ValidationError{Field: "age", Message: "must be less than 150"}
}
return nil
}
func createUser(name string, age int) error {
if err := validateAge(age); err != nil {
return &APIError{
Code: 400,
Message: "invalid input",
Err: err,
}
}
fmt.Printf("Created user: %s (age %d)\n", name, age)
return nil
}
func main() {
err := createUser("Alex", -5)
if err != nil {
fmt.Println("Error:", err)
// Extract the APIError
var apiErr *APIError
if errors.As(err, &apiErr) {
fmt.Printf("Status code: %d\n", apiErr.Code)
}
// Extract the ValidationError from inside APIError
var valErr *ValidationError
if errors.As(err, &valErr) {
fmt.Printf("Invalid field: %s\n", valErr.Field)
}
}
}
Output:
Error: API error 400: invalid input: validation error: age — must be positive
Status code: 400
Invalid field: age
The Unwrap() method is key. It lets errors.As walk from APIError to ValidationError. Without Unwrap, the inner error would be hidden.
panic and recover
panic stops the normal flow of a program. recover catches a panic and lets the program continue. Use them rarely:
package main
import "fmt"
func safeDivide(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered from panic: %v", r)
}
}()
// This panics if b is 0
return a / b, nil
}
func main() {
result, err := safeDivide(10, 2)
fmt.Printf("10 / 2 = %d, err = %v\n", result, err)
result, err = safeDivide(10, 0)
fmt.Printf("10 / 0 = %d, err = %v\n", result, err)
}
Output:
10 / 2 = 5, err = <nil>
10 / 0 = 0, err = recovered from panic: runtime error: integer divide by zero
When to use panic:
- Never in library code (return errors instead)
- Only in
mainor initialization when the program cannot continue - Examples: missing required config file, corrupt database on startup
When to use recover:
- In HTTP server middleware (prevent one bad request from crashing the server)
- At the top of goroutines (prevent one goroutine from crashing the whole program)
Error Handling in Goroutines
Goroutines cannot return errors. Use channels or the errgroup package:
Using Channels
package main
import "fmt"
func doWork(id int, errCh chan<- error) {
if id == 3 {
errCh <- fmt.Errorf("worker %d failed", id)
return
}
fmt.Printf("Worker %d succeeded\n", id)
errCh <- nil
}
func main() {
errCh := make(chan error, 5)
for i := 1; i <= 5; i++ {
go doWork(i, errCh)
}
// Collect errors
for i := 0; i < 5; i++ {
if err := <-errCh; err != nil {
fmt.Println("Error:", err)
}
}
}
Using errgroup
The errgroup package from golang.org/x/sync is the standard way to handle errors in groups of goroutines:
package main
import (
"context"
"fmt"
"time"
"golang.org/x/sync/errgroup"
)
func fetchAPI(ctx context.Context, url string) error {
// Simulate API call
time.Sleep(100 * time.Millisecond)
if url == "https://api.example.com/fail" {
return fmt.Errorf("fetch %s: server error", url)
}
fmt.Printf("Fetched: %s\n", url)
return nil
}
func main() {
g, ctx := errgroup.WithContext(context.Background())
urls := []string{
"https://api.example.com/users",
"https://api.example.com/posts",
"https://api.example.com/fail",
"https://api.example.com/comments",
}
for _, url := range urls {
url := url // Capture for goroutine
g.Go(func() error {
return fetchAPI(ctx, url)
})
}
// Wait for all goroutines and return the first error
if err := g.Wait(); err != nil {
fmt.Println("\nFirst error:", err)
} else {
fmt.Println("\nAll requests succeeded")
}
}
errgroup does three things:
- Starts goroutines with
g.Go() - Waits for all to finish with
g.Wait() - Returns the first error (and cancels the context so other goroutines can stop)
Best Practices
Here is a summary of Go error handling best practices:
// 1. Always wrap errors with context
return fmt.Errorf("parseConfig: %w", err)
// 2. Use errors.Is for sentinel errors
if errors.Is(err, ErrNotFound) { ... }
// 3. Use errors.As for custom error types
var valErr *ValidationError
if errors.As(err, &valErr) { ... }
// 4. Handle errors once — do not log AND return
// Bad:
log.Println("error:", err)
return err
// Good — choose one:
return fmt.Errorf("operation failed: %w", err)
// OR
log.Println("error:", err)
// handle it here, do not return err
// 5. Fail fast — check errors immediately
data, err := readFile(path)
if err != nil {
return err
}
// Continue with data...
// 6. Use errgroup for concurrent operations
g, ctx := errgroup.WithContext(ctx)
Go Errors vs Rust Result
If you are also learning Rust (from the Rust Tutorial series), here is how they compare:
| Feature | Go | Rust |
|---|---|---|
| Error type | error interface | Result<T, E> enum |
| Return style | (value, error) | Result<T, E> |
| Check style | if err != nil | match or ? operator |
| Error chaining | fmt.Errorf("%w", err) | .map_err(), anyhow |
| Multiple errors | errgroup | join! macro |
| Compile-time safety | No (can ignore errors) | Yes (must handle Result) |
Go errors are simpler but rely on programmer discipline. Rust errors are safer but more verbose. For a deeper look at Rust’s approach, see Rust Tutorial #8: Error Handling.
Common Mistakes
1. Using %v instead of %w for wrapping.
// Bad — breaks the error chain
return fmt.Errorf("failed: %v", err)
// Good — preserves the error chain
return fmt.Errorf("failed: %w", err)
With %v, the original error becomes a string. errors.Is() and errors.As() cannot find it anymore.
2. Comparing errors with == instead of errors.Is.
// Bad — does not check wrapped errors
if err == os.ErrNotExist { ... }
// Good — checks the entire chain
if errors.Is(err, os.ErrNotExist) { ... }
3. Panicking for expected errors.
// Bad — panicking for a normal error
data, err := readFile(path)
if err != nil {
panic(err) // Do not do this!
}
// Good — return the error
data, err := readFile(path)
if err != nil {
return nil, fmt.Errorf("readFile: %w", err)
}
panic is for truly unexpected situations. Use normal error returns for everything else.
Source Code
You can find the complete source code for this tutorial on GitHub:
Related Articles
- Go Tutorial #4: Functions and Error Handling — Error handling basics
- Go Tutorial #13: Select, Context, and Patterns — Concurrency patterns
- Rust Tutorial #8: Error Handling — Rust’s Result type approach
What’s Next?
In the next tutorial, Go Tutorial #15: Building HTTP Servers with net/http, you will learn:
- Creating HTTP handlers with
http.HandleFunc - Request and response objects
- JSON encoding and decoding
- Go 1.22+ routing patterns
- Building a complete REST API with the standard library
This is part 14 of the Go Tutorial series. Follow along to learn Go from scratch.