Skip to content
JavaGo
Part III · Functions & Errors

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

ad-hoc, sentinel and typed errors
// 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)
}
Java — custom exception class
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);
Go — custom error type
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

fmt.Errorf with %w builds a readable chain
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

Java — catch by type
try {
    service.publish(42L);
} catch (MessageNotFoundException e) {
    return ResponseEntity.status(404).build();
} catch (ValidationException e) {
    return ResponseEntity.badRequest().body(e.getMessage());
}
Go — match anywhere in the chain
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

Fig. 12.1Wrapping with %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

errors.Join (Go 1.20+): like addSuppressed, but inspectable
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

Java — log and rethrow
} catch (SQLException e) {
    log.error("query failed", e); // logged here...
    throw new ServiceException(e); // ...and again upstream
}
Go — either handle it or return it
// 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 programPanic when…Only a bug could cause it
Return an error when…Input is invalid or a resource is missingPanic when…An internal invariant is broken
Return an error when…A network, disk or database call failsPanic when…Startup configuration makes running pointless
Return an error when…The caller might reasonably retry or recoverPanic when…A Must… helper is used on constant input, like regexp.MustCompile
↑ ↓ to navigateEnter to openEsc to close