beps/docs/proposals/BEP-001-exceptions/legacy-ignore/context/go.md
Go treats errors as first-class values, not as control flow exceptions. This is a deliberate design choice to prioritize explicitness and simplicity over conciseness.
The error interface is minimal:
type error interface {
Error() string
}
Go code often exhibits a "left-aligned" happy path. Errors are handled immediately, usually resulting in a return.
func processUser(id string) (*User, error) {
user, err := db.GetUser(id)
if err != nil {
return nil, fmt.Errorf("failed to get user: %w", err)
}
if err := validateUser(user); err != nil {
return nil, fmt.Errorf("validation failed: %w", err)
}
return user, nil
}
DX Pros:
error).DX Cons:
if err != nil pattern is repetitive (often 50% of lines).err variable can lead to accidental shadowing bugs.Since Go 1.13, the standard library supports error wrapping.
Wrapping:
// Adds context while preserving the original error type for inspection
return fmt.Errorf("access denied for user %s: %w", uid, errPermissionDenied)
Inspection (errors.Is / errors.As):
Instead of == or type assertions, use:
if errors.Is(err, os.ErrNotExist) {
// Handle file not found
}
var pathErr *os.PathError
if errors.As(err, &pathErr) {
// Access pathErr.Path
}
Tradeoff: Go errors are lightweight (just an interface value).
pkg/errors (deprecated but popular) that attach stack traces, which adds allocation overhead.Tradeoff: No exceptions means no "jump up the stack".
defer is the only mechanism that runs on return.panic exists but is reserved for truly unrecoverable state (like nil pointer dereference), not operational errors.var ErrNotFound = errors.New("...")): Fast == checks, but tight coupling to specific values.errors.As.defer in Error Handlingdefer is Go's mechanism for guaranteed cleanup, and it plays a crucial role in error handling patterns. A deferred function call is executed when the surrounding function returns, regardless of whether that return is normal or via panic.
The most common pattern is closing resources (files, connections, locks) while ensuring errors don't get lost:
func processFile(path string) (err error) {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("failed to open: %w", err)
}
defer func() {
closeErr := f.Close()
if closeErr != nil && err == nil {
// Only override if no error exists yet
err = fmt.Errorf("failed to close: %w", closeErr)
}
}()
// Work with f...
return processData(f)
}
Key Insight: Using a named return value (err error) allows the deferred function to modify the return error. This is idiomatic in Go for resource cleanup.
DX Consideration:
err inside the defer.Defer can wrap errors with additional context just before returning:
func updateUser(id string, data UserData) (err error) {
defer func() {
if err != nil {
err = fmt.Errorf("updateUser(id=%s): %w", id, err)
}
}()
// Multiple operations, any might fail
user, err := db.GetUser(id)
if err != nil {
return err
}
user.Update(data)
return db.SaveUser(user)
}
This avoids repeating context at every error return site.
defer/recover for Panic HandlingGo's panic is analogous to exceptions but reserved for truly exceptional cases (programmer errors, unrecoverable state). recover() can catch panics, but only when called from within a deferred function:
func safeHandler(w http.ResponseWriter, r *http.Request) {
defer func() {
if recovered := recover(); recovered != nil {
log.Printf("panic recovered: %v", recovered)
http.Error(w, "Internal Server Error", 500)
}
}()
// Code that might panic (e.g., nil pointer dereference)
riskyOperation()
}
When to Use:
DX Tradeoff:
Defer is often used with database transactions:
func createOrder(ctx context.Context, order Order) (err error) {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback() // Rollback on any error
} else {
err = tx.Commit() // Commit and capture commit errors
}
}()
if err = tx.InsertOrder(order); err != nil {
return err
}
if err = tx.UpdateInventory(order.Items); err != nil {
return err
}
return nil // Commit happens in defer
}
Defers execute in LIFO (last-in, first-out) order:
func example() {
defer fmt.Println("1")
defer fmt.Println("2")
defer fmt.Println("3")
// Prints: 3, 2, 1
}
This matters when managing nested resources:
func processFiles(paths []string) error {
for _, path := range paths {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close() // ⚠️ BUG: All files close at function end, not loop iteration
}
return nil
}
// Fix: Use a separate function to ensure defer runs per iteration
func processFiles(paths []string) error {
for _, path := range paths {
if err := processOneFile(path); err != nil {
return err
}
}
return nil
}
func processOneFile(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close() // ✓ Closes after this iteration
// Process file...
return nil
}
| Language | Cleanup Mechanism | Execution Guarantee |
|---|---|---|
| Go | defer | On return or panic |
| Python | with / finally | On block exit or exception |
| Rust | Drop trait | On scope exit (RAII) |
| Java | try-with-resources | On try block exit |
| C++ | Destructors (RAII) | On scope exit |
Go's defer is explicit (you see the defer call) but order-dependent (LIFO can be surprising). Rust's RAII is implicit but deterministic.
Go optimizes for readability of control flow at the expense of write-time verbosity. It forces developers to consider failure states at every step. defer provides a powerful mechanism for guaranteed cleanup and error propagation, but requires understanding of named returns and execution order to use correctly.
In 2018, the Go team proposed a new error handling design to address the verbosity of if err != nil. The proposal introduced check and handle.
check & handleConcept:
check: An expression that simplifies error checking. If the error is non-nil, it automatically transfers control to a handler.handle: A block of code that acts as a localized error handler.Proposed Syntax:
func CopyFile(src, dst string) error {
handle err {
return fmt.Errorf("copy %s %s: %v", src, dst, err)
}
r := check os.Open(src)
defer r.Close()
w := check os.Create(dst)
handle err {
w.Close()
os.Remove(dst) // Clean up partial file on error
}
check io.Copy(w, r)
check w.Close()
return nil
}
The proposal was ultimately rejected due to overwhelming community feedback.
1. Loss of Local Context & "Error Handling Scope"
Nate Finch argued that check removes the physical space in the code where developers normally add context, log, or clean up for a specific error. To add context for just one call (e.g., distinguishing between "A failed" vs "B failed"), you'd have to remove check and go back to if err != nil.
"With check, that space in the code doesn’t exist. There’s a barrier to making that code handle errors better... Most of the time I want to add information about one specific error case." — Nate Finch, Handle and Check - Let's Not
He also demonstrated that the handle pattern was already possible with closures but rarely used, suggesting it wasn't a missing feature but a design choice to avoid it.
Proposed check/handle syntax:
func printSum(a, b string) error {
handle err { return fmt.Errorf("error summing %v and %v: %v", a, b, err ) }
x := check strconv.Atoi(a)
y := check strconv.Atoi(b)
fmt.Println("result:", x + y)
return nil
}
Equivalent Go 1 code (already possible, but unused):
func printSum(a, b string) (err error) {
check := func(err error) error {
return fmt.Errorf("error summing %v and %v: %v", a, b, err )
}
x, err := strconv.Atoi(a)
if err != nil { return check(err) }
y, err := strconv.Atoi(b)
if err != nil { return check(err) }
fmt.Println("result:", x + y)
return nil
}
2. The "Inscrutable Chain" (Control Flow Obscurity)
Liam Breck highlighted that handle blocks appearing before the code that triggers them is confusing, and the chaining rules (lexical vs. runtime) were subtle. You have to parse the whole function to understand the handler sequence.
"The steps taken on bail-out can be spread across a function and are not labeled... For the following example, cover the comments column and see how it feels…" — Liam Breck, Golang, How dare you handle my checks!
func f() error {
handle err { return ... } // finally this
if ... {
handle err { ... } // not that
for ... {
handle err { ... } // nor that
...
}
}
handle err { ... } // secondly this
...
if ... {
handle err { ... } // not that
...
} else {
handle err { ... } // firstly this
check thisFails() // trigger
}
}
2. Lack of Multiple Handler Pathways
Real-world code often needs different handling logic for different errors (e.g., network error vs. validation error). check/handle forced a single "bail-out" path.
// Common pattern that check/handle struggles to express cleanly:
{ debug.PrintStack(); log.Fatal(err) }
{ log.Println(err) }
{ if err == io.EOF { break } }
{ conn.Write([]byte("oops: " + err.Error())) }
3. Nesting Obscures Order of Operations
Nesting check calls makes the sequence of operations unclear, unlike the linear if err != nil style.
// Which runs first? The order is implicit in the nesting.
check step4(check step1(), check step3(check step2()))
// Compared to:
v1 := step1()
v2 := step2()
v3 := step3(v2)
step4(v1, v3)
4. "Spooky Action at a Distance"
A check at the bottom of a function might jump to a handle block defined at the top, breaking the principle of locality.
"Handle, in my opinion is kind of useless... Check and handle actually make error handling worse. With the check and handle code, there’s no required 'error handling scope' after the calls to add context to the error, log it, clean up, etc." — Nate Finch, Handle and Check - Let's Not
5. Specificity of check
check was specific to the error type as the last return value. It couldn't handle other "exceptional" states, like a bool success flag or a C-style errno (e.g., if errno := f(); errno != 0).
Supporters appreciated the declarative nature and the removal of visual noise.
"Many types of error handling are variations on a few themes: close something, delete something, or notify something... The declarative and deterministic nature of these cleanup policies mean that's relatively rare that the exit (or force kill) of a process yields system-wide instability." — Adam Bouhenguel, In support of simpler, more declarative error handling
The feedback process generated many counter-proposals, highlighting what the community actually valued.
check / ? operator)Many users preferred an inline syntax that didn't require a separate handle block.
Proposal: check in assignment
// From mcluseau's proposal
func chatWithRemote(remote Remote) error {
// Define handlers first (lexical scoping)
handle readErr {
return fmt.Errorf("failed to read: %v", readErr)
}
// Inline check
msg, check readErr := remote.Read()
if msg != "220 test.com ESMTP Postfix" {
return ProtocolError
}
}
Proposal: ? operator (Rust-like)
// Hypothetical syntax preferred by many
func CopyFile(src, dst string) error {
r := os.Open(src)?
defer r.Close()
w := os.Create(dst)?
// ...
}
Explicitly invoking a handler to avoid the "spooky action" of implicit jumping.
check f() ? handlerName
The Go team decided to abandon the check/handle proposal. The consensus was that while the verbosity is a pain point, the explicitness of Go's error handling is a feature, not a bug. The complexity of handle outweighed the benefits of saving a few lines of code.
Current best practices remain:
if err != nil.defer for cleanup.%w) for context.