Error Handling
📋 Jump to Takeaways🎁 Go has no try/catch — you write if err != nil after almost every function call. Annoying boilerplate or genius design that makes bugs impossible to ignore?
Go 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.
Both work by repeatedly calling Unwrap() on each error until they find a match or reach the end.
// errors.Is — match by value
var ErrNotFound = errors.New("not found")
err := fmt.Errorf("lookup failed: %w", ErrNotFound)
fmt.Println(errors.Is(err, ErrNotFound)) // trueerrors.As does the same traversal, but instead of comparing values, it checks if each error's type matches your target. When it finds one, it assigns it to your variable so you can access the typed fields.
// errors.As — match by type, extract the value
func validate() error {
return &ValidationError{Field: "email", Message: "invalid"}
}
func process() error {
return fmt.Errorf("process: %w", validate())
}
err := process()
// err chain: "process: email: invalid" → *ValidationError
var valErr *ValidationError
if errors.As(err, &valErr) {
fmt.Println(valErr.Field) // email
fmt.Println(valErr.Message) // invalid
}
// errors.As walks: is "process:..." a *ValidationError? No.
// Unwrap → is the inner error a *ValidationError? Yes. Assign it.valErr is already a pointer (*ValidationError), so why &valErr? Because valErr starts as nil. errors.As needs to set it to the found error, and to fill a caller's variable, any function needs its address. 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
🎁 Next up: One keyword, go, launches concurrent tasks, and channels let them talk. Let's unlock Go's concurrency model.