Error Handling
📋 Jump to TakeawaysGo doesn't have exceptions. No try/catch, no throw, no hidden control flow. Errors are just values. You return them, check them, wrap them with context, and move on. It feels verbose at first, but it means you always know exactly where things can fail. Every Go developer who's debugged a stack trace in another language eventually appreciates this.
error Is an Interface
In Go, errors are just values. The error type is a built-in interface with a single method.
type error interface {
Error() string
}Any type that implements Error() string is an error.
Returning Errors
Functions return errors as the last return value. This is Go's core error-handling pattern.
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}Checking Errors
Always check errors immediately. Don't ignore them.
result, err := divide(10, 0)
if err != nil {
fmt.Println(err) // division by zero
return
}
fmt.Println(result)Creating Errors
Use errors.New for simple static errors. Use fmt.Errorf when you need formatting.
import "errors"
err1 := errors.New("something failed")
name := "config.yaml"
err2 := fmt.Errorf("file not found: %s", name)Custom Error Types
Create a struct that implements the error interface for errors that carry extra data.
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
func validate(age int) error {
if age < 0 {
return &ValidationError{Field: "age", Message: "must be positive"}
}
return nil
}
err := validate(-1)
fmt.Println(err) // age: must be positiveWrapping Errors
Use fmt.Errorf with %w to wrap an error with context. This preserves the original error for inspection.
func readConfig() error {
err := openFile()
if err != nil {
return fmt.Errorf("readConfig: %w", err)
}
return nil
}Unwrapping: errors.Is and errors.As
errors.Is checks if any error in the chain matches a target value. errors.As extracts a specific error type from the chain.
// In your package — define the sentinel
var ErrNotFound = errors.New("not found")
// In the calling code — wrap and check it
err := fmt.Errorf("lookup failed: %w", ErrNotFound)
// Check by value
fmt.Println(errors.Is(err, ErrNotFound)) // true
// Extract by type
var valErr *ValidationError
if errors.As(err, &valErr) {
fmt.Println(valErr.Field)
}valErr is already a pointer (*ValidationError), so why &valErr? Because valErr starts as nil. errors.As digs through the error chain, finds the matching error, and sets valErr to point to it. To fill in a caller's variable, any function needs its address. Since valErr is a pointer, its address is **ValidationError. Looks odd, but same idea as json.Unmarshal or fmt.Scan.
panic
panic stops normal execution and begins unwinding the stack. Use it only for truly unrecoverable situations: programmer bugs, not user input errors.
func mustEnv(key string) string {
val := os.Getenv(key)
if val == "" {
panic("required env var missing: " + key)
}
return val
}recover
recover catches a panic and returns the panic value. It only works inside a deferred function.
func safeCall() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
}
}()
panic("something broke")
}
safeCall()
fmt.Println("program continues") // program continuesdefer + recover Pattern
This pattern is useful for catching panics at boundaries like HTTP handlers, goroutines, or plugin systems.
func handleRequest() {
defer func() {
if r := recover(); r != nil {
fmt.Println("caught panic:", r)
}
}()
processRequest() // may panic
}Don't Use panic for Normal Error Handling
panic is not Go's exception system. Return errors for expected failures. Reserve panic for impossible states and programming mistakes.
// Wrong — don't do this
func findUser(id int) User {
u, err := db.Find(id)
if err != nil {
panic(err) // bad: callers can't handle this gracefully
}
return u
}
// Right — return the error
func findUser(id int) (User, error) {
return db.Find(id) // returns (User, error), both passed through
}Key Takeaways
erroris an interface — any type withError() stringqualifies- Return errors as the last value; check with
if err != nil - Use
errors.Newfor simple errors,fmt.Errorffor formatted ones - Wrap errors with
%wto add context while preserving the original - Use
errors.Isto match values anderrors.Asto extract types from the chain - Custom error types carry structured data beyond a message string
panicis for unrecoverable bugs, not normal error flowrecoveronly works insidedefer— use it at system boundaries