Errors are values, not control flow
You write a function that reads a file. In Python, you wrap the call in try and handle the exception in except. In JavaScript, you use try/catch or chain .catch(). You switch to Go, write the function, and the compiler rejects the code because you ignored the error. You look for try, you look for catch, and you find nothing. Go doesn't have exceptions. Errors are just values returned by functions.
This design choice feels strange at first. You are used to errors interrupting the flow of your program. Go treats errors as data. A function returns its result and an error. You check the error. If it is not nil, you handle it. If it is nil, you use the result. The flow continues normally. There is no hidden jump to a handler. There is no stack unwinding. The code reads top to bottom, and every failure path is visible in the source.
The cost of hidden control flow
Exceptions hide control flow. When you call a function that might throw, you cannot tell by looking at the call site. You have to read the documentation or inspect the implementation to know what exceptions might bubble up. This makes it hard to reason about error handling. You might forget to catch an exception, and the program crashes with a traceback that points to a line deep in the call stack.
Go makes the error path explicit. The function signature tells you exactly what can go wrong. If a function returns an error, you know you must handle it. The compiler enforces this. You cannot ignore the error unless you explicitly discard it with the blank identifier _. This forces you to think about failures at every step. The verbosity of if err != nil is a feature. It makes the unhappy path visible. The community accepts the boilerplate because it prevents silent failures and makes code easier to audit.
Think of exceptions like a fire alarm. When it goes off, everyone stops and runs to the exit. The normal flow of activity halts. Think of Go errors like a note on a sticky note. The function hands you the result and a note. You decide what to do with the note. You can read it, pass it along, or ignore it. The flow continues. You remain in control.
Minimal example: returning and checking errors
Here is the simplest pattern: a function returns a value and an error. The caller checks the error before using the value.
package main
import (
"fmt"
"os"
)
// readFile reads the content of a file and returns it as a string.
// It returns an error if the file cannot be opened or read.
func readFile(path string) (string, error) {
// os.ReadFile handles open, read, and close internally.
// It returns data and an error, or nil data and a non-nil error.
data, err := os.ReadFile(path)
if err != nil {
// Return the error immediately so the caller knows something went wrong.
// The empty string is a placeholder; the caller won't use it if err is not nil.
return "", err
}
// Convert bytes to string. This conversion never fails, so no error check is needed.
return string(data), nil
}
func main() {
// Call the function and capture both return values.
// The compiler requires you to handle both values.
content, err := readFile("notes.txt")
if err != nil {
// Handle the error at the call site.
// Printing to stderr is a common pattern for command-line tools.
fmt.Fprintln(os.Stderr, "oops:", err)
return
}
// Use the result only if the error is nil.
fmt.Println(content)
}
The compiler rejects the program if you forget to capture the error. If you write content := readFile("notes.txt"), you get assignment mismatch: 1 variable but readFile returns 2 values. You must capture both. If you write content, _ := readFile("notes.txt"), the compiler accepts it, but the underscore signals to the reader that you intentionally discarded the error. Use this sparingly. Discarding errors is usually a mistake.
Walkthrough: what happens at runtime
When main calls readFile, the function executes. os.ReadFile attempts to read the file. If the file exists, it returns the data and a nil error. The if err != nil check fails, so the function returns the string and nil. Back in main, err is nil, so the check fails, and the program prints the content.
If the file is missing, os.ReadFile returns nil data and a non-nil error. The if err != nil check succeeds. The function returns an empty string and the error. Back in main, err is not nil, so the check succeeds. The program prints the error and returns. The flow never jumps. The caller decides what to do. This explicit handling makes it easy to add logging, retry logic, or fallback behavior at any level.
Realistic example: wrapping errors for context
In real code, you often need to add context to errors. If a function calls multiple lower-level functions, the error from the bottom might not tell you where the failure happened. Go provides fmt.Errorf with the %w verb to wrap errors. Wrapping preserves the original error and adds a message. You can unwrap the error later to check the cause.
package main
import (
"fmt"
"io"
"net/http"
)
// fetchURL retrieves data from a URL and returns the body.
// It wraps errors to add context about which URL failed.
func fetchURL(url string) (string, error) {
// http.Get performs the request. It returns a response and an error.
resp, err := http.Get(url)
if err != nil {
// Wrap the error to include the URL.
// The %w verb preserves the original error for checking later.
return "", fmt.Errorf("fetching %s: %w", url, err)
}
// Always close the response body to free resources.
// defer ensures this runs when fetchURL returns, even on error.
defer resp.Body.Close()
// Check the status code. HTTP 200 is success.
if resp.StatusCode != http.StatusOK {
// Wrap the status code error. This is a logical error, not a transport error.
// We don't use %w here because there is no underlying error to wrap.
return "", fmt.Errorf("unexpected status %d for %s", resp.StatusCode, url)
}
// Read the body. This can also fail, so check the error.
body, err := io.ReadAll(resp.Body)
if err != nil {
// Wrap the read error with context.
return "", fmt.Errorf("reading body for %s: %w", url, err)
}
return string(body), nil
}
Error wrapping is the standard way to add context. The %w verb creates a chain of errors. You can use errors.Is or errors.As to check for specific errors anywhere in the chain. This replaces the need for custom error types in many cases. The error interface is simple: type error interface { Error() string }. Any type that implements this method is an error. You can create custom error types when you need to attach extra data, like a status code or a request ID.
Pitfalls and compiler errors
Ignoring errors is the most common mistake. The compiler helps you avoid this, but you can still discard errors with _. If you discard an error, you lose the ability to handle the failure. The program might crash later with a confusing message, or it might produce incorrect results. Always handle errors, even if the handling is just logging and returning.
Another pitfall is confusing errors with panics. panic stops the program and unwinds the stack. It is for programming mistakes or unrecoverable states. Do not use panic for expected failures like missing files or network errors. Use errors for those. If you panic in a library function, the caller has no way to recover. The program crashes. Reserve panic for bugs, like accessing an index out of bounds or a nil pointer dereference.
The compiler catches type mismatches. If you try to return a string where an error is expected, you get cannot use "bad" (untyped string constant) as error value in return argument. You must wrap the string in an error using fmt.Errorf or errors.New. If you forget to import a package, you get undefined: pkg. If you import a package but don't use it, you get imported and not used. Go is strict about unused code. This keeps the codebase clean and reduces dependencies.
Convention aside: the receiver name for methods is usually one or two letters matching the type. Write (b *Buffer) Read(...) instead of (this *Buffer) Read(...). This is a community standard. It keeps method signatures short and readable. The gofmt tool enforces formatting conventions. Trust gofmt. Argue logic, not formatting. Most editors run it on save.
Decision: when to use errors vs alternatives
Use a returned error when the failure is expected and recoverable. This includes file not found, network timeout, invalid input, and permission denied. The caller can handle the error by retrying, falling back, or reporting to the user.
Use panic when the program is in an invalid state and cannot continue. This includes programming bugs, invariant violations, and unrecoverable system failures. Do not use panic for normal error conditions.
Use error wrapping with fmt.Errorf and %w to add context without losing the original cause. This makes debugging easier and allows callers to check for specific errors using errors.Is or errors.As.
Use defer with recover only when you need to catch a panic from a third-party library or a goroutine that might crash. This is rare. Most code should not use recover.
Use errors.Join when a function fails with multiple errors and you want to return all of them. This is useful for cleanup operations where you need to report failures from multiple steps.
Errors are values. Handle them like data. The signature is the documentation. Panic is a bug. Error is a condition.