Skip to content

Error Handling Basics Beginner

Introduction

Go handles errors through explicit return values, not exceptions. Functions that can fail return an error as their last return value, and callers check it immediately. This is Go's most debated design choice — critics say if err != nil is verbose, proponents say it makes error paths visible and forces you to think about failure at every step.

Syntax & Usage

The error Interface

type error interface {
    Error() string
}

That's the entire definition. Any type with an Error() string method satisfies the error interface.

Creating Errors

import (
    "errors"
    "fmt"
)

// Simple static error
err := errors.New("connection refused")

// Formatted error with context
err := fmt.Errorf("failed to connect to %s on port %d", host, port)

The if err != nil Pattern

result, err := doSomething()
if err != nil {
    return fmt.Errorf("doSomething failed: %v", err)
}
// use result -- only reached if err is nil

This is the most common pattern in Go. You'll write it hundreds of times.

Returning Errors from Functions

func readConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("reading config %s: %v", path, err)
    }

    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("parsing config: %v", err)
    }

    return &cfg, nil
}

Conventions:

  • error is always the last return value
  • Return the zero value of other returns alongside the error
  • Return nil for the error on success

Sentinel Errors

Predefined package-level error values used for comparison.

import (
    "database/sql"
    "errors"
    "io"
)

// Reading until EOF
data := make([]byte, 1024)
n, err := reader.Read(data)
if err == io.EOF {
    fmt.Println("reached end of input")
} else if err != nil {
    return err
}

// Checking for "not found" in database queries
row := db.QueryRow("SELECT name FROM users WHERE id = $1", id)
err := row.Scan(&name)
if err == sql.ErrNoRows {
    return nil, fmt.Errorf("user %d not found", id)
} else if err != nil {
    return nil, err
}

Common sentinel errors:

Package Error Meaning
io io.EOF End of input stream
io io.ErrUnexpectedEOF Premature end of input
database/sql sql.ErrNoRows Query returned no results
os os.ErrNotExist File/directory doesn't exist
os os.ErrPermission Permission denied
context context.Canceled Context was canceled
context context.DeadlineExceeded Context deadline passed

Checking for nil Error

func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil // nil means "no error"
}

result, err := divide(10, 3)
if err != nil {
    log.Fatal(err)
}
fmt.Println(result) // 3.3333...

Custom Error Types

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Message)
}

func validateAge(age int) error {
    if age < 0 || age > 150 {
        return &ValidationError{
            Field:   "age",
            Message: "must be between 0 and 150",
        }
    }
    return nil
}

Real-World Pattern: Multiple Error Checks

func createUser(ctx context.Context, req CreateUserRequest) (*User, error) {
    if err := validateRequest(req); err != nil {
        return nil, fmt.Errorf("invalid request: %v", err)
    }

    hashedPw, err := hashPassword(req.Password)
    if err != nil {
        return nil, fmt.Errorf("hashing password: %v", err)
    }

    user := &User{
        Name:     req.Name,
        Email:    req.Email,
        Password: hashedPw,
    }

    if err := db.InsertUser(ctx, user); err != nil {
        return nil, fmt.Errorf("inserting user: %v", err)
    }

    return user, nil
}

Quick Reference

Concept Syntax Notes
Error interface Error() string Any type implementing this is an error
Create error errors.New("msg") Simple static error
Format error fmt.Errorf("context: %v", err) Error with formatting
Check error if err != nil Always check immediately
Return error return nil, err Error is last return value
No error return result, nil nil means success
Sentinel error if err == io.EOF Compare with ==
Custom error type Struct with Error() method Carries structured data

Why Explicit Errors? (The Design Choice)

Go's authors chose explicit error returns over exceptions for these reasons:

Exceptions (Java, Python) Explicit Errors (Go)
Error paths are invisible in code Every error path is visible
Easy to forget to catch Compiler warns on unused values
Stack unwinding is expensive Simple return value — no cost
try/catch can be far from the error Handling is always at the call site
Hidden control flow Linear, predictable control flow

Addressing the "Too Much if err != nil" Criticism

The verbosity is real but intentional. Strategies to keep it manageable:

// 1. Early returns keep the happy path unindented
func process(id int) (*Result, error) {
    item, err := fetch(id)
    if err != nil {
        return nil, err
    }
    // happy path continues at the left margin
    return transform(item), nil
}

// 2. Add context at each level
if err != nil {
    return nil, fmt.Errorf("processing user %d: %v", id, err)
}

// 3. Helper functions reduce repetition
func must[T any](val T, err error) T {
    if err != nil {
        panic(err)
    }
    return val
}
// Use only in init/setup, NEVER in production request handling
tmpl := must(template.ParseFiles("index.html"))

Best Practices

  1. Always check errors — ignoring an error is almost always a bug.
  2. Add context when propagating — fmt.Errorf("opening db: %v", err) tells you where it failed.
  3. Return early on error — keep the happy path at the left margin.
  4. Error is the last return value — the entire ecosystem expects this convention.
  5. Name sentinel errors with Err prefixErrNotFound, ErrTimeout, ErrInvalidInput.
  6. Don't panic for expected errorspanic is for programmer bugs, not runtime conditions.

Common Pitfalls

Ignoring errors

// WRONG -- silently discards the error
result, _ := riskyOperation()

// RIGHT
result, err := riskyOperation()
if err != nil {
    return err
}
The only acceptable time to use _ for errors is when you truly don't care (e.g., fmt.Fprintf to stderr in a cleanup path).

Checking error string content

// FRAGILE -- breaks if error message changes
if err.Error() == "not found" { ... }

// CORRECT -- use sentinel errors or custom types
if err == ErrNotFound { ... }

Shadowing err with :=

err := step1()
if err != nil { return err }

// BUG: this creates a NEW err in the if-block scope
if result, err := step2(); err != nil {
    return err // returns step2's error (correct here)
}
// err here is still step1's err (or nil)

Returning a non-nil interface containing a nil pointer

func getError() error {
    var err *ValidationError // nil pointer
    return err               // non-nil interface wrapping nil pointer!
}

err := getError()
fmt.Println(err == nil) // false! The interface is not nil
Always return nil explicitly: return nil.

Interview Tips

Interview Tip

"Why doesn't Go have exceptions?" Go's designers believe error handling should be explicit, visible, and local. Exceptions create hidden control flow — a function call can jump to a catch block many frames up the stack. Go's approach forces you to handle (or deliberately propagate) errors at every call site, making the code's behavior predictable.

Interview Tip

"What is the error interface?" It's a single-method interface: Error() string. Any type implementing this method satisfies the interface. This simplicity is intentional — it allows both simple string errors and rich structured error types.

Interview Tip

"What are sentinel errors?" Package-level var values like io.EOF and sql.ErrNoRows that callers compare against with ==. They represent well-known, expected error conditions as opposed to unexpected failures. Convention: prefix with Err (e.g., var ErrNotFound = errors.New("not found")).

Interview Tip

"How do you add context to errors?" Use fmt.Errorf("context: %v", err) to wrap the message with additional information. This creates a chain that reads like a stack trace: "creating user: hashing password: crypto/rand: read failed". (For full wrapping with %w, see Error Wrapping in intermediate topics.)

Key Takeaways

  • error is an interface with one method: Error() string.
  • Functions return error as the last return value; nil means success.
  • if err != nil is Go's core error-handling pattern — embrace it.
  • Use errors.New() for simple errors, fmt.Errorf() for formatted context.
  • Sentinel errors (io.EOF, sql.ErrNoRows) represent known conditions.
  • Never ignore errors silently — always check or explicitly discard with _.
  • The verbosity is a feature: every error path is visible in the code.