본문으로 건너뛰기 Go in 2 Weeks #05 | Days 8–9: A New Take on Errors — Forget try/catch

Go in 2 Weeks #05 | Days 8–9: A New Take on Errors — Forget try/catch

Go in 2 Weeks #05 | Days 8–9: A New Take on Errors — Forget try/catch

이 글의 핵심

Learn explicit Go error handling instead of C++ try/catch: multiple return values, the if err != nil pattern, defer for cleanup, and correct use of panic/recover—with practical examples.

Series overview

📚 Go in 2 Weeks #05 | Full series index

This post covers Days 8–9 of the two-week Go curriculum for C++ developers.

Previous: #04 Interfaces ← | → Next: #06 Goroutines & channels


Introduction: the philosophy of explicit errors

In C++, try-catch lets you handle errors in one place. That is convenient, but it is often hard to see where an exception might be thrown. Go does not use exceptions. Instead, errors are returned as values, and callers handle them explicitly. It can feel verbose at first, but control flow stays clear and fewer “surprise” crashes come from unexpected exceptions. You will learn:

  • Passing errors with multiple return values
  • The if err != nil pattern
  • Guaranteed cleanup with defer
  • errors.New, fmt.Errorf, %w, sentinel errors, errors.Is / As
  • Correct use of panic and recover

For C++ developers: This article emphasizes differences and pitfalls when moving from a C++ background—comparing pointers, concurrency, memory management, and more.

How it feels in practice

Teams that mainly ran C++ servers often notice that Go’s syntax and toolchain look simpler. In production, that simplicity often pays off in builds, deploys, and readable concurrent code. Commonly cited strengths:

  • Velocity: Varies by team and domain, but network and CLI code tends to ship quickly.
  • Safety: A GC removes much of the manual freeing burden.
  • Deployment: A single binary is easy to ship.

Table of contents

  1. Errors as values: the error type
  2. The if err != nil pattern
  3. defer: RAII’s replacement
  4. Wrapping and unwrapping errors
  5. errors.New vs fmt.Errorf, sentinels, real-world wrapping
  6. panic and recover
  7. Exercises

1. Errors as values: the error type

C++ vs Go: how errors are handled

// C++: errors via exceptions
#include <iostream>
#include <fstream>
#include <stdexcept>
void readFile(const std::string& path) {
    std::ifstream file(path);
    if (!file) {
        throw std::runtime_error("Failed to open file");
    }
    
    std::string line;
    while (std::getline(file, line)) {
        if (line.empty()) {
            throw std::invalid_argument("Empty line found");
        }
        std::cout << line << "\n";
    }
}
int main() {
    try {
        readFile("data.txt");
    } catch (const std::exception& e) {
        std::cerr << "Error: " << e.what() << "\n";
        return 1;
    }
    return 0;
}
// Go: errors via return values
package main
import (
    "bufio"
    "fmt"
    "os"
)
func readFile(path string) error {
    file, err := os.Open(path)
    if err != nil {
        return fmt.Errorf("failed to open file: %w", err)
    }
    defer file.Close()
    
    scanner := bufio.NewScanner(file)
    for scanner.Scan() {
        line := scanner.Text()
        if line == "" {
            return fmt.Errorf("empty line found")
        }
        fmt.Println(line)
    }
    
    if err := scanner.Err(); err != nil {
        return fmt.Errorf("scan error: %w", err)
    }
    
    return nil
}
func main() {
    if err := readFile("data.txt"); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }
}

Key differences:

  • Go does not throw exceptions
  • Errors are passed as return values
  • Callers must check errors

The error interface

// Go: error is an interface
type error interface {
    Error() string
}
// Custom error type
type FileError struct {
    Path string
    Op   string
    Err  error
}
func (e *FileError) Error() string {
    return fmt.Sprintf("%s %s: %v", e.Op, e.Path, e.Err)
}
// Usage
func openFile(path string) error {
    _, err := os.Open(path)
    if err != nil {
        return &FileError{
            Path: path,
            Op:   "open",
            Err:  err,
        }
    }
    return nil
}

2. The if err != nil pattern

Basic pattern

// Go: the most common pattern
func doSomething() error {
    result, err := someOperation()
    if err != nil {
        return err  // propagate
    }
    
    // use result
    return nil
}

Variations

// Go: different ways to handle errors
package main
import (
    "fmt"
    "os"
)
// 1. Return immediately
func pattern1() error {
    f, err := os.Open("file.txt")
    if err != nil {
        return err
    }
    defer f.Close()
    // ...
    return nil
}
// 2. Log then return
func pattern2() error {
    f, err := os.Open("file.txt")
    if err != nil {
        fmt.Println("Failed to open file:", err)
        return err
    }
    defer f.Close()
    // ...
    return nil
}
// 3. Use a default value
func pattern3() int {
    data, err := fetchData()
    if err != nil {
        return 0  // default
    }
    return data
}
// 4. Retry
func pattern4() error {
    maxRetries := 3
    for i := 0; i < maxRetries; i++ {
        err := tryOperation()
        if err == nil {
            return nil
        }
        fmt.Printf("Attempt %d failed: %v\n", i+1, err)
    }
    return fmt.Errorf("failed after %d retries", maxRetries)
}

3. defer: RAII’s replacement

C++ RAII vs Go defer

// C++: automatic cleanup with RAII
void processFile() {
    std::ifstream file("data.txt");
    std::lock_guard<std::mutex> lock(mtx);
    
    // work...
    
    // On scope exit:
    // 1. lock released (lock_guard destructor)
    // 2. file closed (ifstream destructor)
}
// Go: explicit cleanup with defer
func processFile() error {
    file, err := os.Open("data.txt")
    if err != nil {
        return err
    }
    defer file.Close()  // runs when the function returns
    
    mu.Lock()
    defer mu.Unlock()   // runs when the function returns
    
    // work...
    
    return nil
    // defers run in reverse order:
    // 1. mu.Unlock()
    // 2. file.Close()
}

Defer order (LIFO)

// Go: defer is LIFO (Last In First Out)
package main
import "fmt"
func example() {
    defer fmt.Println("1")
    defer fmt.Println("2")
    defer fmt.Println("3")
    
    fmt.Println("function body")
}
func main() {
    example()
}
// Output:
// function body
// 3
// 2
// 1

When defer arguments are evaluated

// Go: defer arguments are evaluated immediately
package main
import "fmt"
func example() {
    x := 1
    defer fmt.Println("Deferred:", x)  // x == 1 is captured now
    
    x = 2
    fmt.Println("Current:", x)
}
func main() {
    example()
}
// Output:
// Current: 2
// Deferred: 1  (value at defer registration)

Deferred evaluation via a closure:

// Go: wrap in a function to observe the value at run time
func example() {
    x := 1
    defer func() {
        fmt.Println("Deferred:", x)  // uses x when the defer runs
    }()
    
    x = 2
    fmt.Println("Current:", x)
}
// Output:
// Current: 2
// Deferred: 2  (value at execution time)

Practical defer patterns

// Go: defer patterns in production
package main
import (
    "fmt"
    "time"
)
// 1. Timing
func measureTime(name string) func() {
    start := time.Now()
    return func() {
        fmt.Printf("%s took %v\n", name, time.Since(start))
    }
}
func slowFunction() {
    defer measureTime("slowFunction")()  // defer the returned function
    
    time.Sleep(100 * time.Millisecond)
}
// 2. Locking
func criticalSection() {
    mu.Lock()
    defer mu.Unlock()
    
    // unlock is guaranteed even with multiple return paths
    if condition1 {
        return
    }
    if condition2 {
        return
    }
    // ...
}
// 3. Transaction rollback
func transaction() error {
    tx, err := db.Begin()
    if err != nil {
        return err
    }
    
    defer func() {
        if err != nil {
            tx.Rollback()
        }
    }()
    
    // work...
    if err := doWork(tx); err != nil {
        return err  // defer runs Rollback
    }
    
    return tx.Commit()
}

4. Wrapping and unwrapping errors

Error wrapping

// Go: add context by wrapping
package main
import (
    "fmt"
    "os"
)
func readConfig(path string) error {
    data, err := os.ReadFile(path)
    if err != nil {
        // %w preserves the underlying error
        return fmt.Errorf("read config from %s: %w", path, err)
    }
    
    if len(data) == 0 {
        return fmt.Errorf("config file %s is empty", path)
    }
    
    return nil
}
func loadSettings() error {
    if err := readConfig("config.json"); err != nil {
        return fmt.Errorf("load settings: %w", err)
    }
    return nil
}
func main() {
    if err := loadSettings(); err != nil {
        fmt.Println("Error:", err)
        // Error: load settings: read config from config.json: open config.json: no such file or directory
    }
}

errors.Is and errors.As

// Go: test for a specific error
package main
import (
    "errors"
    "fmt"
)
var (
    ErrNotFound    = errors.New("not found")
    ErrUnauthorized = errors.New("unauthorized")
)
func findUser(id int) error {
    if id < 0 {
        return ErrNotFound
    }
    return nil
}
func main() {
    err := findUser(-1)
    
    if errors.Is(err, ErrNotFound) {
        fmt.Println("User not found")
    }
}
// Go: extract a concrete type with errors.As
package main
import (
    "errors"
    "fmt"
    "os"
)
func openFile(path string) error {
    _, err := os.Open(path)
    if err != nil {
        return fmt.Errorf("open file: %w", err)
    }
    return nil
}
func main() {
    err := openFile("nonexistent.txt")
    
    var pathErr *os.PathError
    if errors.As(err, &pathErr) {
        fmt.Println("Path error:")
        fmt.Println("  Op:", pathErr.Op)
        fmt.Println("  Path:", pathErr.Path)
        fmt.Println("  Err:", pathErr.Err)
    }
}

With fmt.Errorf and %w, you build an error chain, so even after several layers you can still use errors.Is(err, os.ErrNotExist) to find the root cause. With %v only, you concatenate strings and Is / As cannot walk the chain.

5. errors.New vs fmt.Errorf, sentinels, real-world wrapping

errors.New vs fmt.Errorf

APIWhen to useNotes
errors.New("msg")Simple errors with fixed messages, often sentinel package-level vars compared with errors.IsNo formatting—single string only
fmt.Errorf("format", args...)When you need dynamic values in the messageFormatting alone is not wrapping
fmt.Errorf("...: %w", err)When you want the cause on the chain (Go 1.13+)Unwrap works → errors.Is / As can traverse
package example
import (
    "errors"
    "fmt"
)
var ErrNotFound = errors.New("not found") // Sentinel — compare with Is
func badUser(id int) error {
    return fmt.Errorf("user %d not found", id) // OK, but use %w to link ErrNotFound
}
func goodUser(id int) error {
    return fmt.Errorf("user %d: %w", id, ErrNotFound) // chain includes ErrNotFound
}

Custom errors and errors.As

  • Use errors.Is to compare values (sentinels or the Unwrap chain).
  • Use errors.As to extract a concrete type and read fields (HTTP status, error codes, etc.).
  • Custom types need only Error() string; add Unwrap() error when you wrap another error.
package example
import "fmt"
type AppError struct {
    Code    int
    Message string
    Err     error
}
func (e *AppError) Error() string {
    return fmt.Sprintf("code=%d: %s", e.Code, e.Message)
}
func (e *AppError) Unwrap() error { return e.Err }

Sentinel errors in practice

  • Declare var ErrXxx = errors.New(...) at package scope to fix meaning.
  • Callers use errors.Is(err, pkg.ErrNotFound); intermediate layers add context with fmt.Errorf("context: %w", err).
  • Creating multiple errors.New with the same string breaks Is. Always use shared variables.

Wrapping checklist

  1. Wrap at failure sites: return fmt.Errorf("doing X: %w", err).
  2. Classify with errors.Is / errors.As (string Contains is a last resort).
  3. Logging: agree when full stacks are needed—usually once at the boundary.

6. panic and recover

panic: unrecoverable mistakes

// C++: throw an exception
void divide(int a, int b) {
    if (b == 0) {
        throw std::invalid_argument("division by zero");
    }
    std::cout << a / b << "\n";
}
// Go: panic (generally discouraged for normal flow)
func divide(a, b int) int {
    if b == 0 {
        panic("division by zero")  // aborts the goroutine
    }
    return a / b
}
// Preferred: return an error
func divideWithError(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

recover: catching a panic

// Go: recover from panic
package main
import "fmt"
func safeDivide(a, b int) (result int, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic recovered: %v", r)
        }
    }()
    
    result = a / b  // panics if b == 0
    return result, nil
}
func main() {
    result, err := safeDivide(10, 0)
    if err != nil {
        fmt.Println("Error:", err)
    } else {
        fmt.Println("Result:", result)
    }
}

When to use panic / recover

SituationRecommendation
Ordinary failures (missing file, network timeout, bad input)Return error
Programming bugs (broken invariants, nil dereference, unreachable branch)May panic—prefer tests to catch these
Must-succeed initialization (regexp.MustCompile, template.Must)Idiomatic panic
Isolate one HTTP requestrecover only makes sense at goroutine boundaries—middleware / handler patterns (ties to #06)
recover works only inside a deferred function. Prefer logging and converting to error (or returning 500) rather than swallowing silently.

Panic guidelines

// Bad: panic for ordinary errors
func fetchUser(id int) *User {
    user, err := db.Query(id)
    if err != nil {
        panic(err)  // don't do this
    }
    return user
}
// Good: return an error
func fetchUser(id int) (*User, error) {
    user, err := db.Query(id)
    if err != nil {
        return nil, fmt.Errorf("fetch user %d: %w", id, err)
    }
    return user, nil
}
// OK: panic for programmer mistakes
func mustCompile(pattern string) *regexp.Regexp {
    re, err := regexp.Compile(pattern)
    if err != nil {
        panic(fmt.Sprintf("invalid regex pattern: %s", pattern))
    }
    return re
}
// Startup-time invariant
var emailRegex = mustCompile(`^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$`)

7. Exercises

Exercise 1: copy a file with defer

// Go: safe file copy with defer
package main
import (
    "fmt"
    "io"
    "os"
)
func copyFile(src, dst string) error {
    srcFile, err := os.Open(src)
    if err != nil {
        return fmt.Errorf("open source: %w", err)
    }
    defer srcFile.Close()  // always closed
    
    dstFile, err := os.Create(dst)
    if err != nil {
        return fmt.Errorf("create destination: %w", err)
    }
    defer dstFile.Close()  // always closed
    
    _, err = io.Copy(dstFile, srcFile)
    if err != nil {
        return fmt.Errorf("copy: %w", err)
    }
    
    return nil
}
func main() {
    if err := copyFile("source.txt", "dest.txt"); err != nil {
        fmt.Fprintf(os.Stderr, "Copy failed: %v\n", err)
        os.Exit(1)
    }
    fmt.Println("Copy successful")
}

Exercise 2: custom error type

// Go: custom error type
package main
import (
    "errors"
    "fmt"
    "strings"
)
type ValidationError struct {
    Field   string
    Value   string
    Message string
}
func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation error [%s=%s]: %s", 
        e.Field, e.Value, e.Message)
}
func validateEmail(email string) error {
    if email == "" {
        return &ValidationError{
            Field:   "email",
            Value:   email,
            Message: "email is required",
        }
    }
    
    if !strings.Contains(email, "@") {
        return &ValidationError{
            Field:   "email",
            Value:   email,
            Message: "invalid email format",
        }
    }
    
    return nil
}
func registerUser(email string) error {
    if err := validateEmail(email); err != nil {
        return fmt.Errorf("register user: %w", err)
    }
    
    // registration logic...
    return nil
}
func main() {
    err := registerUser("invalid")
    
    var validationErr *ValidationError
    if errors.As(err, &validationErr) {
        fmt.Println("Validation failed:")
        fmt.Println("  Field:", validationErr.Field)
        fmt.Println("  Value:", validationErr.Value)
        fmt.Println("  Message:", validationErr.Message)
    }
}

Exercise 3: error chains

// Go: tracing an error chain
package main
import (
    "errors"
    "fmt"
)
var (
    ErrDatabase   = errors.New("database error")
    ErrConnection = errors.New("connection error")
)
func connectDB() error {
    return ErrConnection
}
func queryUser(id int) error {
    if err := connectDB(); err != nil {
        return fmt.Errorf("query user %d: %w", id, err)
    }
    return nil
}
func getProfile(id int) error {
    if err := queryUser(id); err != nil {
        return fmt.Errorf("get profile: %w", err)
    }
    return nil
}
func main() {
    err := getProfile(123)
    
    fmt.Println("Error:", err)
    // Error: get profile: query user 123: connection error
    
    if errors.Is(err, ErrConnection) {
        fmt.Println("Root cause: connection error")
    }
}

Exercise 4: timing with defer

// Go: measure execution time with defer
package main
import (
    "fmt"
    "time"
)
func trace(name string) func() {
    start := time.Now()
    fmt.Printf("Entering %s\n", name)
    
    return func() {
        fmt.Printf("Exiting %s (took %v)\n", name, time.Since(start))
    }
}
func processData() {
    defer trace("processData")()
    
    fmt.Println("Processing...")
    time.Sleep(100 * time.Millisecond)
    fmt.Println("Done")
}
func main() {
    processData()
}
// Output:
// Entering processData
// Processing...
// Done
// Exiting processData (took 100ms)

Wrap-up: Days 8–9 checklist

What to complete

  • Understand the error type and if err != nil
  • Pass errors with multiple return values
  • Use defer for cleanup (RAII-style guarantees)
  • Know defer runs in LIFO order
  • Wrap with fmt.Errorf and %w
  • Distinguish errors.New (sentinels) from fmt.Errorf / %w
  • Inspect errors with errors.Is and errors.As
  • Use panic / recover sparingly
  • Finish all four exercises

From C++ to Go

C++GoNotes
throwreturn errorExplicit
try-catchif err != nilPer call site
RAIIdeferPer function
Exception propagationError return chainClear flow
std::exceptionerror interfaceMinimal surface

What’s next

In Days 8–9 you learned Go error handling. Next: goroutines and channels—where many C++ developers really feel Go’s strength.

Series navigation

PreviousIndexNext
← #04 Interfaces📑 Full index#06 Goroutines & channels →
Go in 2 Weeks:
Curriculum#01 Syntax & philosophy#02 Data structures#03 OOP & composition#04 Interfaces#05 Error handling#06 Goroutines & channels#07 Modules & testing#08 REST API#09 Context & graceful shutdown

One-line summary: Go favors explicit error returns for clear control flow, and defer for reliable cleanup.


Go error handling, if err != nil, defer Go, panic recover, fmt.Errorf, C++ exceptions vs Go, Golang error handling, Go in 2 weeks, and related terms.

Practical tips

Tips you can apply immediately.

Debugging

  • Start from compiler warnings and minimal reproducers.

Performance

  • Do not optimize without profiling; define measurable goals first.

Code review

  • Check common review feedback early; follow team conventions.

Checklist

Before you code

  • Is this the right tool for the problem?
  • Will teammates understand and maintain it?
  • Does it meet performance requirements?

While coding

  • Are warnings cleared?
  • Are edge cases covered?
  • Is error handling appropriate?

At review

  • Is intent obvious?
  • Are tests sufficient?
  • Is documentation in place? Use this checklist to reduce mistakes and improve quality.

FAQ (duplicate of frontmatter)

Q. Where do I use this at work?

A. Use explicit errors instead of C++-style exceptions: multiple returns, if err != nil, defer, and disciplined panic/recover. Apply the patterns and decision table from this article.

Q. What should I read first?

A. Follow Previous post / Related posts at the bottom of each article. The C++ series index shows the full arc.

Q. How do I go deeper?

A. Use cppreference and official library docs. See also links at the end of each post.