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 != nilpattern - Guaranteed cleanup with
defer errors.New,fmt.Errorf,%w, sentinel errors,errors.Is/As- Correct use of
panicandrecover
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
- Errors as values: the
errortype - The
if err != nilpattern defer: RAII’s replacement- Wrapping and unwrapping errors
errors.Newvsfmt.Errorf, sentinels, real-world wrappingpanicandrecover- 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
| API | When to use | Notes |
|---|---|---|
errors.New("msg") | Simple errors with fixed messages, often sentinel package-level vars compared with errors.Is | No formatting—single string only |
fmt.Errorf("format", args...) | When you need dynamic values in the message | Formatting 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.Isto compare values (sentinels or theUnwrapchain). - Use
errors.Asto extract a concrete type and read fields (HTTP status, error codes, etc.). - Custom types need only
Error() string; addUnwrap() errorwhen 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 withfmt.Errorf("context: %w", err). - Creating multiple
errors.Newwith the same string breaksIs. Always use shared variables.
Wrapping checklist
- Wrap at failure sites:
return fmt.Errorf("doing X: %w", err). - Classify with
errors.Is/errors.As(stringContainsis a last resort). - 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
| Situation | Recommendation |
|---|---|
| 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 request | recover 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
errortype andif err != nil - Pass errors with multiple return values
- Use
deferfor cleanup (RAII-style guarantees) - Know defer runs in LIFO order
- Wrap with
fmt.Errorfand%w - Distinguish
errors.New(sentinels) fromfmt.Errorf/%w - Inspect errors with
errors.Isanderrors.As - Use
panic/recoversparingly - Finish all four exercises
From C++ to Go
| C++ | Go | Notes |
|---|---|---|
throw | return error | Explicit |
try-catch | if err != nil | Per call site |
| RAII | defer | Per function |
| Exception propagation | Error return chain | Clear flow |
std::exception | error interface | Minimal 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
| Previous | Index | Next |
|---|---|---|
| ← #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.
Related reading (internal links)
- [Go in 2 Weeks #04] Day 7: Interfaces — polymorphism without
virtual - Two-week Go curriculum for C++ developers
- C++ exception basics | try/catch/throw vs error codes
Keywords (search)
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.
Related posts
- Two-week Go curriculum for C++ developers
- C++ vs Go — performance, concurrency, and how to choose
- How C++ developers map concepts to Go
- [Go in 2 Weeks #01] Days 1–2: Philosophy and syntax
- [Go in 2 Weeks #02] Days 3–4: Memory and data structures