Errors as Values
Series
Go for JVM EngineersThe most common complaint from JVM engineers picking up Go is the repetitive if err != nil blocks. That reaction is understandable but misses what the design achieves: errors are ordinary values that travel through the call stack as explicitly as any other return value. There is no hidden control flow, no checked-exception list that grows with every signature change, and no runtime surprise when an unchecked exception escapes a thread pool.
Once you accept the model, the tooling around it — wrapping, unwrapping, errors.Is, and errors.As — turns out to be more composable than Java's exception hierarchy.
The Basic Contract
A function that can fail returns (result, error) as its last two values. The caller decides what to do with the error immediately. There is no throws clause and no implicit propagation.
// Java — exception, implicit propagation
public User findUser(String id) throws UserNotFoundException {
// caller may or may not handle this
}// Go — error is an explicit return value
func findUser(id string) (User, error) {
u, ok := store[id]
if !ok {
return User{}, fmt.Errorf("user %q not found", id)
}
return u, nil
}
u, err := findUser("42")
if err != nil {
log.Printf("lookup failed: %v", err)
return
}
// u is safe to use hereThe error type is a built-in interface with a single method: Error() string. Any type that implements that method is an error.
Sentinel Errors and errors.Is
A sentinel error is a package-level variable used as a well-known error value — the Go equivalent of a specific exception class.
// sql package
var ErrNoRows = errors.New("sql: no rows in result set")
// caller
row, err := db.QueryRowContext(ctx, query, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}errors.Is walks the error chain. Even if the error was wrapped three layers deep, Is finds it. This is why you should use errors.Is rather than == for sentinel comparison.
// Java equivalent pattern
try {
return repo.findById(id);
} catch (EmptyResultDataAccessException e) {
throw new NotFoundException(id);
}Wrapping Errors for Context
fmt.Errorf with the %w verb wraps an error, preserving the original for later unwrapping.
func loadConfig(path string) (Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return Config{}, fmt.Errorf("loadConfig: reading %s: %w", path, err)
}
// ...
}The resulting error message reads as a chain: loadConfig: reading /etc/app.yaml: open /etc/app.yaml: no such file or directory. This is the Go equivalent of a chained exception message, but it costs nothing unless you print it.
errors.As — Extracting Typed Error Information
errors.As is the replacement for instanceof casting in Java catch blocks. It walks the chain and, if a matching type is found, populates the target pointer.
// Java
try {
parseConfig(input);
} catch (ParseException e) {
System.err.println("at offset " + e.getErrorOffset());
}// Go
type ParseError struct {
Offset int
Msg string
}
func (e *ParseError) Error() string {
return fmt.Sprintf("parse error at offset %d: %s", e.Offset, e.Msg)
}
_, err := parseConfig(input)
var pe *ParseError
if errors.As(err, &pe) {
fmt.Printf("at offset %d\n", pe.Offset)
}Custom Error Types
For errors that carry structured data beyond a string, define a struct that implements the error interface. Add an Unwrap() error method to participate in chain traversal.
type NotFoundError struct {
Resource string
ID string
Cause error
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s with id %q not found", e.Resource, e.ID)
}
func (e *NotFoundError) Unwrap() error { return e.Cause }Callers can then match with errors.As(err, &NotFoundError{}) regardless of how many wrapping layers sit above it.
Panic Is Not for Ordinary Errors
Go has panic and recover, but they are reserved for programmer errors — index out of bounds, nil dereference, type assertion failures — not for business logic failures. Using panic for flow control is the Go equivalent of throwing RuntimeException for a "user not found" case. It is not idiomatic and it breaks composability.
The rule of thumb: if a caller can reasonably be expected to handle the failure, return an error. If the program has reached an impossible state, panic.
// Correct: return error for recoverable failure
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
// Panic only for precondition violations in constructors/init
func mustPositive(n int) int {
if n <= 0 {
panic(fmt.Sprintf("mustPositive: got %d", n))
}
return n
}Handling Multiple Errors in a Pipeline
A common pattern is accumulating errors across a set of operations, similar to a validation step that collects all field errors rather than stopping at the first.
import "errors"
func validateUser(u User) error {
var errs []error
if u.Name == "" {
errs = append(errs, errors.New("name is required"))
}
if u.Age < 0 {
errs = append(errs, errors.New("age must be non-negative"))
}
return errors.Join(errs...) // Go 1.20+
}errors.Join returns nil if the slice is empty, and a multi-error otherwise. errors.Is and errors.As both traverse multi-errors.
Key Takeaways
- Errors are values; treat them as first-class return values and handle them at the call site rather than propagating exceptions implicitly.
- Use
errors.Is— not==— for sentinel comparison so wrapped errors are still matched correctly. - Wrap with
fmt.Errorf("context: %w", err)to add caller context without losing the original error for inspection. - Use
errors.Asto extract typed error data, replacing Java'scatch (SpecificException e)pattern. - Reserve
panicfor programmer errors and impossible states, not for domain failures a caller can recover from. errors.Join(Go 1.20+) handles multi-error accumulation cleanly, useful for validators and batch operations.