Error Handling
Errors are values: sentinel errors, custom types, wrapping with %w, errors.Is and errors.As, joining, and when a panic is acceptable.
Section 12 of 31 6 min read
An error in Go is any value that implements the built-in error interface, which has one method: Error() string. Functions return errors as their last result; callers check them. There are no exceptions, no throws clauses and no stack unwinding for ordinary failures.
The philosophical difference
Java: failures interrupt normal flow. An exception unwinds the stack until something catches it, and a caller can be unaware that a call might fail at all (unchecked exceptions) or be forced to handle failures it cannot do anything about (checked ones).
Go: failures are ordinary return values. They do not propagate unless you return them. Each caller decides: handle it here, add context and return it, or deliberately ignore it where everyone can see. The code is longer, and every failure path is visible when you read top to bottom.
Three kinds of error values
// 1. Ad-hoc: a one-off message, nobody needs to match on it
return errors.New("config: empty database URL")
return fmt.Errorf("config: port %d out of range", port)
// 2. Sentinel: a package-level value callers compare against.
// Named Err..., like io.EOF and sql.ErrNoRows.
var ErrNotFound = errors.New("not found")
// 3. Typed: a struct carrying data, named ...Error
type ValidationError struct {
Field string
Issue string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Issue)
}class MessageNotFoundException extends RuntimeException {
private final long messageId;
MessageNotFoundException(long id) {
super("message " + id + " not found");
this.messageId = id;
}
long getMessageId() { return messageId; }
}
throw new MessageNotFoundException(42L);type NotFoundError struct {
Resource string
ID int64
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s %d not found", e.Resource, e.ID)
}
return Message{}, &NotFoundError{Resource: "message", ID: 42}Wrapping: adding context as errors travel up
func (s *Store) GetByID(ctx context.Context, id int64) (Message, error) {
var m Message
err := s.db.QueryRowContext(ctx, query, id).Scan(&m.ID, &m.Topic)
if errors.Is(err, sql.ErrNoRows) {
return Message{}, &NotFoundError{Resource: "message", ID: id}
}
if err != nil {
return Message{}, fmt.Errorf("query message %d: %w", id, err)
}
return m, nil
}
func (svc *Service) Publish(ctx context.Context, id int64) error {
m, err := svc.store.GetByID(ctx, id)
if err != nil {
return fmt.Errorf("publish: %w", err) // add context, keep the cause
}
...
}
// err.Error() reads like a stack trace written by humans:
// "publish: query message 42: dial tcp 10.0.0.5:5432: connect: connection refused"Inspecting errors: errors.Is and errors.As
try {
service.publish(42L);
} catch (MessageNotFoundException e) {
return ResponseEntity.status(404).build();
} catch (ValidationException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}err := svc.Publish(ctx, 42)
// Is: compare against a sentinel value, through every %w layer
if errors.Is(err, context.DeadlineExceeded) { ... }
// AsType (Go 1.26+): find an error of a given type
if nf, ok := errors.AsType[*NotFoundError](err); ok {
http.Error(w, nf.Error(), http.StatusNotFound)
return
}
// Before Go 1.26: errors.As with a target pointer
var verr *ValidationError
if errors.As(err, &verr) {
http.Error(w, verr.Error(), http.StatusBadRequest)
return
}Each layer wraps the error with %w
errors.Is and errors.As check every layer
%w nests each error inside the next, so the message reads like a trail of what was happening and the original cause stays inside. errors.Is and errors.As unwrap layer by layer to find it; == only ever sees the outermost error.Multiple errors at once
func (c Config) Validate() error {
var errs []error
if c.DatabaseURL == "" {
errs = append(errs, &ValidationError{Field: "database_url", Issue: "required"})
}
if c.Port <= 0 || c.Port > 65535 {
errs = append(errs, &ValidationError{Field: "port", Issue: "must be 1-65535"})
}
return errors.Join(errs...) // nil when errs is empty
}
// errors.Is and errors.As search every joined error.
// fmt.Errorf also accepts several %w verbs in one call.Handle an error once
} catch (SQLException e) {
log.error("query failed", e); // logged here...
throw new ServiceException(e); // ...and again upstream
}// Anti-pattern: log AND return. The same failure
// appears in the logs once per layer.
if err != nil {
slog.Error("query failed", "err", err)
return err
}
// Idiomatic: wrap and return; log once at the top
// (the HTTP handler or main), where you decide what to do.
if err != nil {
return fmt.Errorf("load orders: %w", err)
}Errors vs panics
| Return an error when… | Panic when… |
|---|---|
| Return an error when…The failure can happen in a correct program | Panic when…Only a bug could cause it |
| Return an error when…Input is invalid or a resource is missing | Panic when…An internal invariant is broken |
| Return an error when…A network, disk or database call fails | Panic when…Startup configuration makes running pointless |
| Return an error when…The caller might reasonably retry or recover | Panic when…A Must… helper is used on constant input, like regexp.MustCompile |