Writing Go code for the first time often feels like a rite of passage. Your program compiles, the terminal stays quiet, and for a moment, you feel invincible. Then reality strikes: if err != nil appears. Again. And again. And again.
file, err := os.Open("dreams.txt")
if err != nil {
return err
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
return err
}
result, err := process(data)
if err != nil {
return err
}It’s repetitive, yes. But that repetition isn’t just noise—it’s a deliberate design. In Go, errors aren’t exceptions waiting to ambush your flow. They’re data. First-class values that live in your function signatures, visible, traceable, and impossible to ignore without consequence.
Why Go’s Error Model Works Better Than You Think
Languages like Python or Java treat errors like uninvited guests—loud, disruptive, and someone else’s problem. Go, by contrast, treats them like postal mail: persistent, predictable, and always addressed.
This approach delivers real benefits:
- Explicit contracts: Every function that returns an error signals it clearly in its signature.
- No hidden jumps: Errors don’t teleport across stack frames. You handle them where they occur.
- Tooling support: Linters like
staticcheckflag ignored errors, turning carelessness into compiler-level feedback.
The trade-off? You’ll type if err != nil more than you’d like. But that repetition forces you to ask: What does this failure mean? Who needs to know? That’s not boilerplate. It’s craftsmanship.
Five Patterns to Handle Errors Like a Go Pro
1. Plain Return: Keep It Simple
func loadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}Use this when the caller already has the context to act on the error. If the message unexpected end of JSON input conveys enough meaning, a bare return suffices.
2. Error Wrapping: Add Context Without Losing Details
func loadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("loadConfig: reading %s: %w", path, err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("loadConfig: parsing %s: %w", path, err)
}
return &cfg, nil
}The %w verb in fmt.Errorf wraps the original error. Downstream code can still inspect it using errors.Is or errors.As—even after multiple layers of wrapping. Use %v and you’ve converted a traceable error into a lifeless string.
3. Sentinel Errors: When Specificity Matters
var (
ErrNotFound = errors.New("user: not found")
ErrUnauthorized = errors.New("user: unauthorized")
ErrRateLimited = errors.New("user: rate limited, chill out")
)
func GetUser(id string) (*User, error) {
if id == "" {
return nil, ErrNotFound
}
// ...
}The caller can now use errors.Is to check for specific conditions:
user, err := GetUser(id)
if errors.Is(err, ErrNotFound) {
return c.JSON(404, "user not found")
}
if err != nil {
return c.JSON(500, "internal server error")
}errors.Is walks the entire error chain, making it perfect for identifying root causes buried under layers of context.
4. Custom Error Types: Attach Data When Needed
Sometimes a string isn’t enough. You need structure.
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 validateEmail(email string) error {
if !strings.Contains(email, "@") {
return &ValidationError{
Field: "email",
Message: "missing @ symbol, are you okay?",
}
}
return nil
}Now the caller can extract the full error context:
err := validateEmail(input)
var vErr *ValidationError
if errors.As(err, &vErr) {
log.Printf("Field %q failed: %s", vErr.Field, vErr.Message)
}errors.As acts like a type assertion for errors, letting you pull out structured data even from wrapped chains.
5. Panic and Recover: Use Sparingly, if Ever
Panics in Go are like fireworks—spectacular when they work, catastrophic when they don’t. Reserve them for truly unrecoverable states:
- Corrupt program state
- Critical initialization failures in
init()functions - Internal package boundaries where you recover and convert to an error
func MustCompile(pattern string) *Regexp {
re, err := Compile(pattern)
if err != nil {
panic(err) // only safe during startup
}
return re
}If you’re using panic to control normal flow, you’re not just breaking idiomatic Go—you’re inviting undefined behavior and tooling that will flag your code as unsafe.
Quick Reference: Which Pattern to Use
| Situation | Best Approach | |-----------|---------------| | Propagating without adding context | return err | | Adding execution context | fmt.Errorf("doing X: %w", err) | | Checking for specific errors | Sentinel error + errors.Is | | Extracting error data | Custom type + errors.As | | Truly unrecoverable failure | panic (sparingly) |
The Real Win: Turning Repetition Into Responsibility
Yes, you’ll write if err != nil often. But each instance is a checkpoint—a moment to decide: What does this failure mean? What should the caller do? That’s not tedium. That’s design.
Go’s error model forces clarity. It turns silent bugs into visible decisions. It turns rushed code into deliberate architecture.
So the next time you type if err != nil, don’t groan. See it as a prompt: This is where your code gets better.
AI summary
Learn Go’s error handling patterns: wrapping, sentinel errors, and custom types. Master `errors.Is`, `errors.As`, and when to avoid panic.