Error Handling in Go
Go treats errors as values, not exceptions. This leads to explicit, visible error handling throughout your code.
The Error Interface
type error interface {
Error() string
}
Any type with an Error() string method is an error.
Basic Error Handling
result, err := doSomething()
if err != nil {
// Handle error
return err // or handle it another way
}
// Use result
This pattern is everywhere in Go. Embrace it.
Creating Errors
Simple Errors
import "errors"
// errors.New
err := errors.New("something went wrong")
// fmt.Errorf (formatted)
err := fmt.Errorf("failed to open file: %s", filename)
Sentinel Errors
Predefined errors that can be checked with ==:
var (
ErrNotFound = errors.New("not found")
ErrUnauthorized = errors.New("unauthorized")
ErrInvalidInput = errors.New("invalid input")
)
func GetUser(id int) (*User, error) {
user := db.Find(id)
if user == nil {
return nil, ErrNotFound
}
return user, nil
}
// Checking
user, err := GetUser(123)
if err == ErrNotFound {
// Handle not found specifically
}
Custom Error Types
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error on %s: %s", e.Field, e.Message)
}
func ValidateEmail(email string) error {
if !strings.Contains(email, "@") {
return &ValidationError{
Field: "email",
Message: "must contain @",
}
}
return nil
}
// Type assertion to access fields
err := ValidateEmail("invalid")
if ve, ok := err.(*ValidationError); ok {
fmt.Println("Field:", ve.Field)
}
Error Wrapping (Go 1.13+)
Wrap errors to add context while preserving the original:
import "fmt"
func ReadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
// Wrap with %w verb
return nil, fmt.Errorf("reading config %s: %w", path, err)
}
var config Config
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
return &config, nil
}
Unwrapping Errors
import "errors"
// errors.Unwrap - get the wrapped error
originalErr := errors.Unwrap(wrappedErr)
// errors.Is - check if error chain contains a specific error
if errors.Is(err, ErrNotFound) {
// Handle not found, even if wrapped
}
if errors.Is(err, os.ErrNotExist) {
// File doesn't exist
}
// errors.As - check if error chain contains a specific type
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Println("Path:", pathErr.Path)
}
var validationErr *ValidationError
if errors.As(err, &validationErr) {
fmt.Println("Invalid field:", validationErr.Field)
}
Implementing Unwrap
For custom error types that wrap other errors:
type QueryError struct {
Query string
Err error
}
func (e *QueryError) Error() string {
return fmt.Sprintf("query %q failed: %v", e.Query, e.Err)
}
func (e *QueryError) Unwrap() error {
return e.Err
}
// Now errors.Is and errors.As work through QueryError
Error Handling Patterns
Return Early
// Good: return early, happy path at the end
func ProcessFile(path string) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("opening file: %w", err)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return fmt.Errorf("reading file: %w", err)
}
if err := validate(data); err != nil {
return fmt.Errorf("validating: %w", err)
}
return process(data)
}
Handle Once
Handle errors at one place: either handle it or return it, not both.
// Bad: logging and returning
if err != nil {
log.Printf("error: %v", err) // Logged here
return err // And caller might log again!
}
// Good: just return with context
if err != nil {
return fmt.Errorf("processing user %d: %w", userID, err)
}
// Handle at the top level
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
Ignore Errors Explicitly
If you must ignore an error, be explicit:
// Make it clear this is intentional
_ = file.Close()
// Or with a comment
file.Close() // Error ignored: file was opened read-only
Must Functions
For initialization where errors are truly fatal:
func MustOpen(path string) *os.File {
f, err := os.Open(path)
if err != nil {
panic(err)
}
return f
}
// Usage during initialization
var configFile = MustOpen("config.json")
// Standard library examples
regexp.MustCompile(`\d+`) // Panics if invalid regex
template.Must(template.New("").Parse("..."))
Use sparingly, only for truly unrecoverable initialization errors.
Multiple Errors
Collecting Errors
func ValidateUser(u User) error {
var errs []error
if u.Name == "" {
errs = append(errs, errors.New("name is required"))
}
if u.Email == "" {
errs = append(errs, errors.New("email is required"))
}
if u.Age < 0 {
errs = append(errs, errors.New("age cannot be negative"))
}
if len(errs) > 0 {
return errors.Join(errs...) // Go 1.20+
}
return nil
}
// Go 1.20+: errors.Join creates a multi-error
err := errors.Join(err1, err2, err3)
// errors.Is works with joined errors
if errors.Is(err, ErrNotFound) {
// At least one of the joined errors is ErrNotFound
}
Before Go 1.20
type MultiError struct {
Errors []error
}
func (m *MultiError) Error() string {
var msgs []string
for _, e := range m.Errors {
msgs = append(msgs, e.Error())
}
return strings.Join(msgs, "; ")
}
func (m *MultiError) Unwrap() []error {
return m.Errors
}
Error Messages
Good Error Messages
// Good: lowercase, no punctuation, provides context
return fmt.Errorf("reading config file %s: %w", path, err)
// Good: chain of context
// "starting server: loading config: reading config file config.json: open config.json: no such file or directory"
// Bad: uppercase, periods, redundant
return fmt.Errorf("Error: Failed to read file. %w", err)
// Bad: too vague
return errors.New("something went wrong")
Error Message Convention
- Start with lowercase
- No ending punctuation
- Add context going up the stack
- Include relevant values (IDs, paths, etc.)
Panic and Recover
For truly exceptional cases (not normal errors):
// Panic stops normal execution
func mustPositive(n int) int {
if n <= 0 {
panic("n must be positive")
}
return n
}
// Recover catches panics
func safeCall(fn func()) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic recovered: %v", r)
}
}()
fn()
return nil
}
When to use panic:
- Programming errors (impossible states, invalid arguments that indicate bugs)
- Initialization failures that make the program unusable
- Never for expected runtime errors (file not found, network errors, etc.)
Error Handling with Defer
func WriteFile(path string, data []byte) (err error) {
f, err := os.Create(path)
if err != nil {
return err
}
// Handle Close error
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = cerr
}
}()
_, err = f.Write(data)
return err
}
Checking Error Types (Summary)
import "errors"
// Check for specific error value
if err == ErrNotFound { ... }
if errors.Is(err, ErrNotFound) { ... } // Works through wrapping
// Check for specific error type
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Println("Path:", pathErr.Path)
}
// Type switch
switch e := err.(type) {
case *ValidationError:
fmt.Println("Field:", e.Field)
case *NetworkError:
fmt.Println("Retrying...")
default:
fmt.Println("Unknown error:", e)
}
Real-World Example
package user
import (
"errors"
"fmt"
)
var (
ErrNotFound = errors.New("user not found")
ErrDuplicateEmail = errors.New("email already exists")
)
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
type Service struct {
db Database
}
func (s *Service) Create(u *User) error {
if err := s.validate(u); err != nil {
return fmt.Errorf("validating user: %w", err)
}
exists, err := s.db.EmailExists(u.Email)
if err != nil {
return fmt.Errorf("checking email: %w", err)
}
if exists {
return ErrDuplicateEmail
}
if err := s.db.Insert(u); err != nil {
return fmt.Errorf("inserting user: %w", err)
}
return nil
}
func (s *Service) validate(u *User) error {
if u.Name == "" {
return &ValidationError{Field: "name", Message: "required"}
}
if u.Email == "" {
return &ValidationError{Field: "email", Message: "required"}
}
return nil
}
// Usage
err := service.Create(user)
if err != nil {
if errors.Is(err, ErrDuplicateEmail) {
// Show user-friendly message
}
var valErr *ValidationError
if errors.As(err, &valErr) {
// Highlight the invalid field
}
// Log full error with context
log.Printf("creating user: %v", err)
}
Next Steps
Continue to 09-concurrency.md to learn about goroutines and channels.