Error Handling Basics Beginner¶
Introduction¶
Go handles errors through explicit return values, not exceptions. Functions that can fail return an error as their last return value, and callers check it immediately. This is Go's most debated design choice — critics say if err != nil is verbose, proponents say it makes error paths visible and forces you to think about failure at every step.
Syntax & Usage¶
The error Interface¶
That's the entire definition. Any type with an Error() string method satisfies the error interface.
Creating Errors¶
import (
"errors"
"fmt"
)
// Simple static error
err := errors.New("connection refused")
// Formatted error with context
err := fmt.Errorf("failed to connect to %s on port %d", host, port)
The if err != nil Pattern¶
result, err := doSomething()
if err != nil {
return fmt.Errorf("doSomething failed: %v", err)
}
// use result -- only reached if err is nil
This is the most common pattern in Go. You'll write it hundreds of times.
Returning Errors from Functions¶
func readConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config %s: %v", path, err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing config: %v", err)
}
return &cfg, nil
}
Conventions:
erroris always the last return value- Return the zero value of other returns alongside the error
- Return
nilfor the error on success
Sentinel Errors¶
Predefined package-level error values used for comparison.
import (
"database/sql"
"errors"
"io"
)
// Reading until EOF
data := make([]byte, 1024)
n, err := reader.Read(data)
if err == io.EOF {
fmt.Println("reached end of input")
} else if err != nil {
return err
}
// Checking for "not found" in database queries
row := db.QueryRow("SELECT name FROM users WHERE id = $1", id)
err := row.Scan(&name)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("user %d not found", id)
} else if err != nil {
return nil, err
}
Common sentinel errors:
| Package | Error | Meaning |
|---|---|---|
io |
io.EOF |
End of input stream |
io |
io.ErrUnexpectedEOF |
Premature end of input |
database/sql |
sql.ErrNoRows |
Query returned no results |
os |
os.ErrNotExist |
File/directory doesn't exist |
os |
os.ErrPermission |
Permission denied |
context |
context.Canceled |
Context was canceled |
context |
context.DeadlineExceeded |
Context deadline passed |
Checking for nil Error¶
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil // nil means "no error"
}
result, err := divide(10, 3)
if err != nil {
log.Fatal(err)
}
fmt.Println(result) // 3.3333...
Custom Error Types¶
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on %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
}
Real-World Pattern: Multiple Error Checks¶
func createUser(ctx context.Context, req CreateUserRequest) (*User, error) {
if err := validateRequest(req); err != nil {
return nil, fmt.Errorf("invalid request: %v", err)
}
hashedPw, err := hashPassword(req.Password)
if err != nil {
return nil, fmt.Errorf("hashing password: %v", err)
}
user := &User{
Name: req.Name,
Email: req.Email,
Password: hashedPw,
}
if err := db.InsertUser(ctx, user); err != nil {
return nil, fmt.Errorf("inserting user: %v", err)
}
return user, nil
}
Quick Reference¶
| Concept | Syntax | Notes |
|---|---|---|
| Error interface | Error() string |
Any type implementing this is an error |
| Create error | errors.New("msg") |
Simple static error |
| Format error | fmt.Errorf("context: %v", err) |
Error with formatting |
| Check error | if err != nil |
Always check immediately |
| Return error | return nil, err |
Error is last return value |
| No error | return result, nil |
nil means success |
| Sentinel error | if err == io.EOF |
Compare with == |
| Custom error type | Struct with Error() method |
Carries structured data |
Why Explicit Errors? (The Design Choice)¶
Go's authors chose explicit error returns over exceptions for these reasons:
| Exceptions (Java, Python) | Explicit Errors (Go) |
|---|---|
| Error paths are invisible in code | Every error path is visible |
| Easy to forget to catch | Compiler warns on unused values |
| Stack unwinding is expensive | Simple return value — no cost |
| try/catch can be far from the error | Handling is always at the call site |
| Hidden control flow | Linear, predictable control flow |
Addressing the "Too Much if err != nil" Criticism¶
The verbosity is real but intentional. Strategies to keep it manageable:
// 1. Early returns keep the happy path unindented
func process(id int) (*Result, error) {
item, err := fetch(id)
if err != nil {
return nil, err
}
// happy path continues at the left margin
return transform(item), nil
}
// 2. Add context at each level
if err != nil {
return nil, fmt.Errorf("processing user %d: %v", id, err)
}
// 3. Helper functions reduce repetition
func must[T any](val T, err error) T {
if err != nil {
panic(err)
}
return val
}
// Use only in init/setup, NEVER in production request handling
tmpl := must(template.ParseFiles("index.html"))
Best Practices¶
- Always check errors — ignoring an error is almost always a bug.
- Add context when propagating —
fmt.Errorf("opening db: %v", err)tells you where it failed. - Return early on error — keep the happy path at the left margin.
- Error is the last return value — the entire ecosystem expects this convention.
- Name sentinel errors with
Errprefix —ErrNotFound,ErrTimeout,ErrInvalidInput. - Don't panic for expected errors —
panicis for programmer bugs, not runtime conditions.
Common Pitfalls¶
Ignoring errors
// WRONG -- silently discards the error
result, _ := riskyOperation()
// RIGHT
result, err := riskyOperation()
if err != nil {
return err
}
_ for errors is when you truly don't care (e.g., fmt.Fprintf to stderr in a cleanup path).
Checking error string content
Shadowing err with :=
Returning a non-nil interface containing a nil pointer
func getError() error {
var err *ValidationError // nil pointer
return err // non-nil interface wrapping nil pointer!
}
err := getError()
fmt.Println(err == nil) // false! The interface is not nil
nil explicitly: return nil.
Interview Tips¶
Interview Tip
"Why doesn't Go have exceptions?" Go's designers believe error handling should be explicit, visible, and local. Exceptions create hidden control flow — a function call can jump to a catch block many frames up the stack. Go's approach forces you to handle (or deliberately propagate) errors at every call site, making the code's behavior predictable.
Interview Tip
"What is the error interface?" It's a single-method interface: Error() string. Any type implementing this method satisfies the interface. This simplicity is intentional — it allows both simple string errors and rich structured error types.
Interview Tip
"What are sentinel errors?" Package-level var values like io.EOF and sql.ErrNoRows that callers compare against with ==. They represent well-known, expected error conditions as opposed to unexpected failures. Convention: prefix with Err (e.g., var ErrNotFound = errors.New("not found")).
Interview Tip
"How do you add context to errors?" Use fmt.Errorf("context: %v", err) to wrap the message with additional information. This creates a chain that reads like a stack trace: "creating user: hashing password: crypto/rand: read failed". (For full wrapping with %w, see Error Wrapping in intermediate topics.)
Key Takeaways¶
erroris an interface with one method:Error() string.- Functions return
erroras the last return value;nilmeans success. if err != nilis Go's core error-handling pattern — embrace it.- Use
errors.New()for simple errors,fmt.Errorf()for formatted context. - Sentinel errors (
io.EOF,sql.ErrNoRows) represent known conditions. - Never ignore errors silently — always check or explicitly discard with
_. - The verbosity is a feature: every error path is visible in the code.