Skip to main content

Error Basics

In Go, error handling is done through return values rather than exceptions. The convention is to return an error as the last return value when a function can fail. This simple design makes code predictable and explicit.

The error Interface​

error is a built-in Go interface with just one method.

package main

import (
"errors"
"fmt"
)

// The error interface definition (Go standard library)
// type error interface {
// Error() string
// }

// errors.New β€” simplest way to create an error
var ErrNotFound = errors.New("not found")
var ErrPermission = errors.New("permission denied")

func findUser(id int) (string, error) {
users := map[int]string{
1: "Alice",
2: "Bob",
}
name, ok := users[id]
if !ok {
return "", ErrNotFound
}
return name, nil
}

func main() {
// Success case
name, err := findUser(1)
if err != nil {
fmt.Println("error:", err)
} else {
fmt.Println("user:", name) // user: Alice
}

// Failure case
name, err = findUser(99)
if err != nil {
fmt.Println("error:", err) // error: not found
} else {
fmt.Println("user:", name)
}

// Error comparison (sentinel error)
if err == ErrNotFound {
fmt.Println("user not found") // user not found
}
}

fmt.Errorf β€” Formatted Error Messages​

Use fmt.Errorf when you need error messages containing dynamic information.

package main

import (
"fmt"
)

func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, fmt.Errorf("divide by zero: %v / %v", a, b)
}
return a / b, nil
}

func openFile(path string) error {
// Simulation: fail on certain paths
if path == "" {
return fmt.Errorf("openFile: path cannot be empty")
}
if path == "/etc/shadow" {
return fmt.Errorf("openFile %q: permission denied", path)
}
return nil
}

func main() {
// Using divide
result, err := divide(10, 0)
if err != nil {
fmt.Println("divide failed:", err) // divide failed: divide by zero: 10 / 0
} else {
fmt.Println("result:", result)
}

result, err = divide(10, 3)
if err != nil {
fmt.Println("divide failed:", err)
} else {
fmt.Printf("result: %.4f\n", result) // result: 3.3333
}

// Using openFile
for _, path := range []string{"", "/etc/shadow", "/tmp/data.txt"} {
if err := openFile(path); err != nil {
fmt.Println("file open failed:", err)
} else {
fmt.Printf("file open success: %q\n", path)
}
}
// file open failed: openFile: path cannot be empty
// file open failed: openFile "/etc/shadow": permission denied
// file open success: "/tmp/data.txt"
}

Error Return Conventions​

Conventions to follow when returning errors in Go.

package main

import (
"errors"
"fmt"
)

// Convention 1: error is always the last return value
func readConfig(path string) (string, error) {
if path == "" {
return "", errors.New("config path is required")
}
return "config content", nil
}

// Convention 2: return nil on success
func processData(data []byte) ([]byte, error) {
if len(data) == 0 {
return nil, errors.New("data is empty")
}
result := make([]byte, len(data))
copy(result, data)
return result, nil
}

// Convention 3: when error occurs, other return values are meaningless (use zero value)
func parseInt(s string) (int, error) {
if s == "" {
return 0, errors.New("empty string") // 0 is a meaningless zero value
}
var n int
for _, c := range s {
if c < '0' || c > '9' {
return 0, fmt.Errorf("invalid character: %c", c)
}
n = n*10 + int(c-'0')
}
return n, nil
}

// Convention 4: error variable is named err (single), or descriptive names (multiple)
func multipleOperations() error {
_, err := readConfig("app.yaml")
if err != nil {
return err
}

_, err = processData([]byte("data"))
if err != nil {
return err
}

return nil
}

// Convention 5: package-level sentinel errors use Err prefix
var (
ErrEmpty = errors.New("empty input")
ErrInvalid = errors.New("invalid input")
ErrTimeout = errors.New("operation timed out")
)

func validate(input string) error {
if input == "" {
return ErrEmpty
}
if len(input) > 100 {
return ErrInvalid
}
return nil
}

func main() {
// Pattern 1: immediate if err != nil check
config, err := readConfig("app.yaml")
if err != nil {
fmt.Println("config load failed:", err)
return
}
fmt.Println("config:", config)

// Pattern 2: inline error handling
if n, err := parseInt("123"); err != nil {
fmt.Println("parse failed:", err)
} else {
fmt.Println("parse success:", n) // parse success: 123
}

if _, err := parseInt("12a3"); err != nil {
fmt.Println("parse failed:", err) // parse failed: invalid character: a
}

// Pattern 3: sentinel error comparison
inputs := []string{"", "valid", "too_long_input_exceeding_hundred_characters_limit_yes_this_is_very_long_string_here_to_test"}
for _, s := range inputs {
if err := validate(s); err != nil {
switch err {
case ErrEmpty:
fmt.Println("empty input")
case ErrInvalid:
fmt.Println("invalid input")
default:
fmt.Println("unknown error:", err)
}
} else {
fmt.Printf("valid input: %q\n", s)
}
}
}

Error and nil Handling​

Patterns for correctly propagating and handling errors.

package main

import (
"errors"
"fmt"
)

var ErrNotFound = errors.New("not found")
var ErrDBConn = errors.New("database connection failed")

// Layered error propagation β€” each layer passes the error up
type DB struct{ connected bool }

func (db *DB) Query(id int) (string, error) {
if !db.connected {
return "", ErrDBConn
}
if id <= 0 {
return "", ErrNotFound
}
return fmt.Sprintf("record-%d", id), nil
}

type Repository struct{ db *DB }

func (r *Repository) FindByID(id int) (string, error) {
record, err := r.db.Query(id)
if err != nil {
return "", err // propagate error as-is
}
return record, nil
}

type Service struct{ repo *Repository }

func (s *Service) GetUser(id int) (string, error) {
user, err := s.repo.FindByID(id)
if err != nil {
return "", err // propagate error as-is
}
return "User: " + user, nil
}

func main() {
// Normal operation
db := &DB{connected: true}
repo := &Repository{db: db}
svc := &Service{repo: repo}

user, err := svc.GetUser(42)
if err != nil {
fmt.Println("error:", err)
} else {
fmt.Println(user) // User: record-42
}

// DB connection failure
db2 := &DB{connected: false}
repo2 := &Repository{db: db2}
svc2 := &Service{repo: repo2}

_, err = svc2.GetUser(42)
if err != nil {
fmt.Println("error:", err) // error: database connection failed
if errors.Is(err, ErrDBConn) {
fmt.Println("β†’ DB connection error detected") // β†’ DB connection error detected
}
}

// Record not found
_, err = svc.GetUser(-1)
if err != nil {
fmt.Println("error:", err) // error: not found
if errors.Is(err, ErrNotFound) {
fmt.Println("β†’ record not found") // β†’ record not found
}
}
}

Collecting Multiple Errors​

Pattern for collecting errors from multiple operations and handling them together.

package main

import (
"errors"
"fmt"
"strings"
)

// MultiError β€” collects multiple errors into one type
type MultiError struct {
Errors []error
}

func (m *MultiError) Add(err error) {
if err != nil {
m.Errors = append(m.Errors, err)
}
}

func (m *MultiError) Error() string {
msgs := make([]string, len(m.Errors))
for i, err := range m.Errors {
msgs[i] = err.Error()
}
return strings.Join(msgs, "; ")
}

func (m *MultiError) Unwrap() []error {
return m.Errors
}

func (m *MultiError) HasError() bool {
return len(m.Errors) > 0
}

// Validation β€” collect all errors and return at once
type UserInput struct {
Name string
Email string
Age int
}

func validateUser(u UserInput) error {
var merr MultiError

if u.Name == "" {
merr.Add(errors.New("name is required"))
} else if len(u.Name) < 2 {
merr.Add(errors.New("name must be at least 2 characters"))
}

if u.Email == "" {
merr.Add(errors.New("email is required"))
} else if !strings.Contains(u.Email, "@") {
merr.Add(errors.New("email must contain @"))
}

if u.Age < 0 {
merr.Add(errors.New("age cannot be negative"))
} else if u.Age > 150 {
merr.Add(errors.New("age is too large"))
}

if merr.HasError() {
return &merr
}
return nil
}

func main() {
inputs := []UserInput{
{Name: "Alice", Email: "alice@example.com", Age: 30},
{Name: "", Email: "invalid", Age: -1},
{Name: "B", Email: "bob@example.com", Age: 200},
}

for _, input := range inputs {
err := validateUser(input)
if err != nil {
fmt.Printf("validation failed (%+v):\n %v\n", input, err)

// Type assert to MultiError
var merr *MultiError
if errors.As(err, &merr) {
fmt.Printf(" total %d error(s)\n", len(merr.Errors))
}
} else {
fmt.Printf("valid input: %+v\n", input)
}
}
}

Key Summary

  • error is an interface with a single method (Error() string)
  • Use errors.New for static errors, fmt.Errorf for dynamic errors
  • Error is always the last return value; return nil on success
  • Package-level sentinel errors use Err prefix (e.g., ErrNotFound)
  • Never ignore errors β€” discarding with _ is dangerous