Skip to content
ioob.dev
Go back

Go Basics Part 4 — Error Handling

· 4 min read
Go Series (4/12)
  1. Go Basics Part 1 — Variables, Constants, and Types
  2. Go Basics Part 2 — Conditionals and Loops
  3. Go Basics Part 3 — Functions
  4. Go Basics Part 4 — Error Handling
  5. Go Basics Part 5 — Arrays, Slices, and Maps
  6. Go Basics Part 6 — Structs and Methods
  7. Go Part 7 — Interfaces
  8. Go Part 8 — Pointers
  9. Go Part 9 — Goroutines and Channels
  10. Go Part 10 — Concurrency Patterns
  11. Go Part 11 — Packages and Modules
  12. Go Part 12 — Generics and Practical Patterns
Table of contents

Table of contents

Go Doesn’t Throw Exceptions

In Java or Python, when something goes wrong, you throw an exception. You either catch it with try-catch or you don’t. Go chose not to follow this approach. Instead, it returns errors as values.

package main

import (
    "fmt"
    "os"
)

func main() {
    f, err := os.Open("nonexistent.txt")
    if err != nil {
        fmt.Println("failed to open file:", err)
        return
    }
    defer f.Close()
    fmt.Println("file opened successfully")
}

os.Open returns both a file and an error. If the error is not nil, something went wrong. This pattern might feel repetitive, and the Go community acknowledges this. But the reason for sticking with it is clear: it makes errors hard to ignore.

In exception-based languages, if you don’t write a catch block, errors either silently disappear or the program suddenly crashes. In Go, if you don’t handle a returned error, gofmt or linters will raise a warning.

The error Interface

Go’s error is a simple interface.

type error interface {
    Error() string
}

Any type that implements just the Error() method can be an error. This simplicity is the heart of Go’s error system.

The simplest way to create an error is with errors.New.

package main

import (
    "errors"
    "fmt"
)

func validate(age int) error {
    if age < 0 {
        return errors.New("age cannot be negative")
    }
    if age > 150 {
        return errors.New("please enter a realistic age")
    }
    return nil
}

func main() {
    if err := validate(-5); err != nil {
        fmt.Println("validation failed:", err)
    }
}

When there’s no error, return nil. Checking with if err != nil on the caller side is the basic rhythm of Go code.

Adding Context with fmt.Errorf

When you want to add extra information to an error message, use fmt.Errorf.

package main

import (
    "fmt"
    "os"
)

func readConfig(path string) error {
    _, err := os.Open(path)
    if err != nil {
        return fmt.Errorf("failed to read config file (%s): %w", path, err)
    }
    return nil
}

func main() {
    err := readConfig("/etc/myapp/config.yaml")
    if err != nil {
        fmt.Println(err)
    }
}

The %w verb is important here. Unlike %v, %w wraps the original error, preserving it. This allows you to later inspect the original error with errors.Is or errors.As.

The pattern of adding context to errors while maintaining the chain is essential in practice. The debugging difficulty is entirely different between seeing just “file open failed” in the logs versus “failed to read config file (/etc/myapp/config.yaml): open /etc/myapp/config.yaml: no such file or directory.”

Here’s a visualization of how wrapping with %w builds an error chain.

flowchart LR
    OS["os.Open failed<br/>*PathError<br/>(no such file...)"] --> Wrap1["fmt.Errorf('readFile failed: %w', err)"]
    Wrap1 --> Wrap2["fmt.Errorf('failed to read config file (%s): %w', path, err)"]
    Wrap2 --> Log[Final error message]
    Log -.->|"errors.Is"| OS
    Log -.->|"errors.As"| OS

errors.Is and errors.As

When errors are wrapped, errors.Is is how you check for a specific error.

package main

import (
    "errors"
    "fmt"
    "os"
)

func readFile(path string) error {
    _, err := os.Open(path)
    if err != nil {
        return fmt.Errorf("readFile failed: %w", err)
    }
    return nil
}

func main() {
    err := readFile("nonexistent.txt")
    if err != nil {
        if errors.Is(err, os.ErrNotExist) {
            fmt.Println("file does not exist")
        } else {
            fmt.Println("other error:", err)
        }
    }
}

errors.Is walks the error chain looking for a match. Since == comparison can’t find wrapped errors, you must develop the habit of using errors.Is.

When you need to extract an error of a specific type, use errors.As.

package main

import (
    "errors"
    "fmt"
    "os"
)

func main() {
    _, err := os.Open("nonexistent.txt")
    if err != nil {
        var pathErr *os.PathError
        if errors.As(err, &pathErr) {
            fmt.Println("path:", pathErr.Path)
            fmt.Println("operation:", pathErr.Op)
        }
    }
}

errors.As finds a specific type in the error chain and assigns it to a variable. It’s useful when you need to access additional information from the error.

Custom Error Types

By directly implementing the error interface, you can include any information you want in your errors.

package main

import "fmt"

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("%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
}

func main() {
    err := validateAge(200)
    if err != nil {
        fmt.Println(err)  // age: must be between 0 and 150
    }
}

As long as there’s an Error() string method, the error interface is satisfied. You can freely include fields, codes, causes, and other information in the struct, which makes this pattern widely used for building API error responses or categorizing errors.

defer

defer schedules code to run when the function exits. It’s used for cleanup tasks like closing files, releasing locks, or closing connections.

package main

import (
    "fmt"
    "os"
)

func main() {
    f, err := os.Create("test.txt")
    if err != nil {
        fmt.Println("failed to create file:", err)
        return
    }
    defer f.Close()  // Runs when main exits

    f.WriteString("Hello, Go!")
    fmt.Println("file written successfully")
}

The key point of defer is scheduling the close right when you open the resource. Since the open and close are near each other, you won’t forget. It serves a similar role to Java’s try-finally or Python’s with, but is much lighter.

When there are multiple defer statements, they execute in LIFO (last in, first out) order.

package main

import "fmt"

func main() {
    fmt.Println("start")
    defer fmt.Println("first defer")
    defer fmt.Println("second defer")
    defer fmt.Println("third defer")
    fmt.Println("end")
}
// start
// end
// third defer
// second defer
// first defer

They stack up and execute in reverse order. This design naturally aligns with closing resources in the reverse order of opening them.

One thing to note: arguments passed to defer are evaluated at the point the defer is declared.

package main

import "fmt"

func main() {
    x := 10
    defer fmt.Println("deferred x:", x)  // 10 is captured
    x = 20
    fmt.Println("x:", x)  // x: 20
}
// x: 20
// deferred x: 10

panic and recover

panic immediately halts the program. recover recovers from a panic. These two should not be used for everyday error handling. They’re only for truly unrecoverable situations.

package main

import "fmt"

func safeDiv(a, b int) (result int, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic recovered: %v", r)
        }
    }()

    return a / b, nil  // Panics if b is 0
}

func main() {
    result, err := safeDiv(10, 0)
    if err != nil {
        fmt.Println("error:", err)
        return
    }
    fmt.Println("result:", result)
}

recover only works inside a defer. Calling it in regular code always returns nil.

In practice, cases where panic is appropriate are extremely limited. It’s reserved for situations like missing required configuration at program startup, or detecting a programming error that should never occur.

func mustParseURL(rawURL string) *url.URL {
    u, err := url.Parse(rawURL)
    if err != nil {
        panic(fmt.Sprintf("invalid URL: %s", rawURL))
    }
    return u
}

Functions in the Go standard library starting with Must (template.Must, regexp.MustCompile, etc.) follow this pattern. If initialization fails, there’s no reason to keep the program running, so they just trigger a panic.

Always handle normal errors with error return values, and use panic only as a last resort.


The next part covers Go’s collections. We’ll look at the difference between arrays and slices, the internal structure of slices, and how to work with maps.

-> Part 5: Arrays, Slices, and Maps


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Go Basics Part 3 — Functions
Next Post
Go Basics Part 5 — Arrays, Slices, and Maps