Errors
Explicit failure: error values, wrapping, errors.Is / errors.As.
Errors in Go
Go has no exceptions for normal failures. A function that can fail returns an error as its last value, and the caller checks it. Verbose, yes — but every failure is visible at the call site.
The error interface
Built-in:
type error interface {
Error() string
}
Anything with an Error() string method satisfies error. The most common way to create one:
import "errors"
err := errors.New("invalid input")
err2 := fmt.Errorf("could not fetch user %d: %w", id, originalErr)
fmt.Errorf lets you format with context. The %w verb wraps another error so callers can unwrap it later.
The if-err idiom
You'll see this pattern hundreds of times in any Go codebase:
data, err := os.ReadFile("config.json")
if err != nil {
return fmt.Errorf("read config: %w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return fmt.Errorf("parse config: %w", err)
}
The chain of if err != nil { return ... } is the cost of explicit failure handling. It does mean every failure path is local + visible.
Custom error types
Wrap context in a struct that implements error:
type NotFoundError struct {
Resource string
ID int64
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s %d not found", e.Resource, e.ID)
}
func GetUser(id int64) (*User, error) {
user := db.find(id)
if user == nil {
return nil, &NotFoundError{Resource: "user", ID: id}
}
return user, nil
}
The caller can check with errors.As to extract the typed value:
var nfe *NotFoundError
if errors.As(err, &nfe) {
log.Printf("missing %s id=%d", nfe.Resource, nfe.ID)
}
Sentinel errors
For specific failure cases, define a package-level error variable:
var ErrNotFound = errors.New("not found")
var ErrUnauthorized = errors.New("unauthorized")
func GetUser(id int64) (*User, error) {
user := db.find(id)
if user == nil {
return nil, ErrNotFound
}
return user, nil
}
// Caller:
user, err := GetUser(id)
if errors.Is(err, ErrNotFound) {
return nil, http.StatusNotFound
}
errors.Is walks the wrap chain — it works even after the error has been wrapped with fmt.Errorf("...: %w", err).
errors.Is vs errors.As
| Helper | Purpose |
|---|---|
errors.Is(err, target) |
Is err (or anything it wraps) equal to target? Used with sentinel errors. |
errors.As(err, &target) |
Is err (or anything it wraps) of the type of *target? If so, assign it. Used with custom error types. |
// Sentinel — compare values.
if errors.Is(err, sql.ErrNoRows) { ... }
// Custom type — extract structured info.
var pgErr *pq.Error
if errors.As(err, &pgErr) {
log.Printf("postgres %s", pgErr.Code)
}
panic vs error
panic is for programmer bugs and unrecoverable invariant violations, not normal failure. Reaching for panic in normal code paths is non-idiomatic Go.
// Normal failure: return an error.
if !user.IsActive {
return errors.New("inactive user")
}
// Programmer bug: panic.
if mode != "read" && mode != "write" {
panic("invalid mode: " + mode)
}
recover (called only inside a deferred function) catches a panic and lets you decide what to do. Most code never needs to recover — let the panic crash the program loudly so the bug gets noticed.
Don't ignore errors
// Bad — silent failure.
data, _ := os.ReadFile("config.json")
// Good — handle it.
data, err := os.ReadFile("config.json")
if err != nil {
log.Fatal(err)
}
The blank identifier _ is fine when you genuinely don't care (e.g., defer f.Close() where the file is read-only). It's a footgun anywhere else.