Go’s error handling philosophy is “simple, explicit, controllable.” Every function that can fail can return an error value, and the caller must explicitly handle it. No exceptions, no implicit propagation—everything is in plain sight.
The error Interface: One Value, One Method
Go’s error interface is minimal: just one Error() string method.
1
2
3
| type error interface {
Error() string
}
|
This means any type that implements Error() string is an error. The Go standard library provides many built-in error types, most commonly errors.New and fmt.Errorf.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| package main
import (
"errors"
"fmt"
)
func main() {
// Create a basic error
err := errors.New("file not found")
fmt.Println(err) // Output: file not found
// Use fmt.Errorf to format error messages
path := "/data/config.yaml"
err2 := fmt.Errorf("failed to read config %s", path)
fmt.Println(err2) // Output: failed to read config /data/config.yaml
}
|
Errors are essentially values. You can return them, compare them, store them, even pass them to other functions.
Error Wrapping and Unwrapping
In large programs, error chains are crucial. Go 1.13 introduced error wrapping, allowing you to add context without losing the original error.
fmt.Errorf and %w
The %w verb in fmt.Errorf wraps the original error instead of replacing it.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| package main
import (
"errors"
"fmt"
)
func readFile() error {
return errors.New("failed to open file")
}
func processConfig() error {
err := readFile()
// Use %w to wrap the original error
return fmt.Errorf("cannot read config: %w", err)
}
func main() {
err := processConfig()
if err != nil {
fmt.Println(err) // Output: cannot read config: failed to open file
}
}
|
errors.Is: Check Error Type
errors.Is traverses the error chain to check for a matching error.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| package main
import (
"errors"
"fmt"
)
var (
ErrNotFound = errors.New("resource not found")
)
func findUser(id int) error {
if id <= 0 {
return ErrNotFound
}
return nil
}
func main() {
err := findUser(-1)
if errors.Is(err, ErrNotFound) {
fmt.Println("handling not found error")
}
}
|
If your error is a custom type, errors.As can extract it.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| package main
import (
"errors"
"fmt"
)
// Custom error type
type NotFoundError struct {
Resource string
ID int
}
func (e NotFoundError) Error() string {
return fmt.Sprintf("%s %d not found", e.Resource, e.ID)
}
func findUser(id int) error {
if id <= 0 {
return NotFoundError{Resource: "user", ID: id}
}
return nil
}
func main() {
err := findUser(-1)
var notFound NotFoundError
if errors.As(err, ¬Found) {
fmt.Printf("Details: %s, ID: %d\n", notFound.Resource, notFound.ID)
}
}
|
errors.Unwrap: Unwrap One Layer
errors.Unwrap removes the outermost wrapper and returns the inner error.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| package main
import (
"errors"
"fmt"
)
func inner() error {
return errors.New("inner error")
}
func outer() error {
return fmt.Errorf("outer wrapper: %w", inner())
}
func main() {
err := outer()
fmt.Println("Original error:", err) // outer wrapper: inner error
fmt.Println("Unwrapped once:", errors.Unwrap(err)) // inner error
}
|
panic and recover: Only for Truly Unrecoverable Errors
Go’s panic and recover are similar to exception mechanisms in other languages, but their use is extremely limited: only when the program encounters a truly unrecoverable error (like nil pointer dereference). Never use panic for regular error handling.
panic: Trigger Fatal Errors
panic interrupts the current function execution and starts stack unwinding.
1
2
3
4
5
6
7
8
9
10
11
12
13
| package main
import "fmt"
func riskyOperation() {
fmt.Println("Starting risky operation")
panic("fatal error occurred")
fmt.Println("This line won't execute")
}
func main() {
riskyOperation()
}
|
recover: Catch panic
recover must be called in a defer function to catch a panic.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| package main
import "fmt"
func safelyDo() {
defer func() {
if r := recover(); r != nil {
fmt.Println("Caught panic:", r)
}
}()
panic("something went wrong")
}
func main() {
safelyDo()
fmt.Println("Program continues executing")
}
|
defer with recover Pattern
This is the standard pattern for handling panic: recover in a defer.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| package main
import "fmt"
func doWork() (err error) {
defer func() {
if r := recover(); r != nil {
// Convert panic to error
err = fmt.Errorf("panic converted to error: %v", r)
}
}()
// Code that might trigger panic
panic("unrecoverable error")
return nil
}
func main() {
err := doWork()
if err != nil {
fmt.Println("Caught error:", err)
}
}
|
Remember: panic/recover is the last resort. If you can handle it with error, don’t use panic.
errors.Join: Combine Multiple Errors (Go 1.20+)
Go 1.20 introduced errors.Join, which can combine multiple errors into one.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
| package main
import (
"errors"
"fmt"
)
func validateName(name string) error {
if name == "" {
return errors.New("name cannot be empty")
}
return nil
}
func validateAge(age int) error {
if age < 0 || age > 150 {
return errors.New("age must be between 0 and 150")
}
return nil
}
func main() {
var errs []error
if err := validateName(""); err != nil {
errs = append(errs, err)
}
if err := validateAge(-1); err != nil {
errs = append(errs, err)
}
if len(errs) > 0 {
combinedErr := errors.Join(errs...)
fmt.Println("Validation failed:", combinedErr)
}
}
|
The error returned by errors.Join can be checked with errors.Is and errors.As against all combined errors.
Best Practices
When to Use error vs panic
- Use error: File read failures, network timeouts, invalid user input, etc.—expected errors.
- Use panic: Nil pointer dereference, array out of bounds, failed type assertion, etc.—unrecoverable program errors.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // Wrong: using panic for file reading
func readFile() string {
data, err := os.ReadFile("config.yaml")
if err != nil {
panic(err) // Don't do this
}
return string(data)
}
// Correct: using error for file reading
func readFile() (string, error) {
data, err := os.ReadFile("config.yaml")
if err != nil {
return "", err
}
return string(data), nil
}
|
Error Logging
Don’t log errors in low-level functions. Return them to the caller, and let the caller decide whether to log.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // Don't do this
func process() error {
err := doSomething()
if err != nil {
log.Printf("processing failed: %v", err) // Logging too early
return err
}
return nil
}
// Correct approach
func process() error {
return doSomething() // Just return error, don't log
}
func main() {
if err := process(); err != nil {
log.Printf("processing failed: %v", err) // Logged by caller
}
}
|
Error Wrapping Layers
When wrapping errors, provide meaningful context, but don’t repeatedly wrap.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| // Don't repeatedly wrap
func readFile() error {
return fmt.Errorf("read file failed: %w", fmt.Errorf("open file failed: %w", errors.New("permission denied")))
}
// Correct: wrap one layer of context per layer
func openFile() error {
return errors.New("permission denied")
}
func readFile() error {
err := openFile()
return fmt.Errorf("read file failed: %w", err)
}
|
Error Handling Consistency
Maintain consistency in error handling across your project: use errors.New to define error variables uniformly, and use uniform wrapping methods.
1
2
3
4
5
6
| // Define standard error variables
var (
ErrNotFound = errors.New("resource not found")
ErrInvalidInput = errors.New("invalid input")
ErrPermission = errors.New("permission denied")
)
|
Summary
Go’s error handling seems simple, but behind it lies profound design philosophy: explicit over implicit, simple over complex. Multi-return values make error handling intuitive, the error interface provides enough flexibility, error wrapping makes error chains clear, and panic/recover provides the last line of defense for truly unrecoverable situations.
Remember: Errors are values, not exceptions. Use error correctly and use panic sparingly, and your Go programs will be more robust and reliable.