DEV Community

Cover image for Exceptions Made You Sloppy. Go's if err != nil Fixes That
Gabriel Anhaia
Gabriel Anhaia

Posted on

Exceptions Made You Sloppy. Go's if err != nil Fixes That


You wrote PHP for ten years. You opened your first Go file. You saw five if err != nil { return err } lines in a 30-line function and you closed the tab. Most ex-PHP devs do, and the annual Go Developer Survey keeps surfacing error-handling boilerplate as a recurring complaint among engineers coming from exception-based languages.

Stay with it for two weeks and something flips. The if err != nil stops looking like ceremony and starts looking like the thing your PHP code never made you do: name every place this function can fail, in order, at the line where it fails.

The exception-as-goto problem

Pull up any Laravel controller. Count the lines that can fail. Then count the try/catch blocks. The numbers will not match. They never match.

public function checkout(Request $req): JsonResponse
{
    $user = User::findOrFail($req->user_id);
    $cart = $this->cart->load($user);
    $total = $this->pricing->compute($cart);
    $charge = $this->stripe->charge($user, $total);
    $order = Order::create([
        'user_id' => $user->id,
        'amount'  => $total,
        'charge'  => $charge->id,
    ]);
    Mail::to($user)->send(new OrderConfirmed($order));

    return response()->json(['order' => $order->id]);
}
Enter fullscreen mode Exit fullscreen mode

Eight lines of body. Zero try. Twelve possible failure modes if you count carefully:

  • findOrFail throws ModelNotFoundException on a missing user.
  • cart->load can hit the DB and throw QueryException.
  • pricing->compute can divide by zero on a malformed cart.
  • stripe->charge can throw ApiErrorException, CardException, RateLimitException, network timeout.
  • Order::create can throw QueryException on a constraint violation.
  • Mail::to(...)->send can throw on a transport failure if the mail queue is sync.
  • The JSON response can throw on circular references.
  • Any of these can be turned into an HTTP 500 by the framework's exception handler, swallowed, logged, or routed to a custom handler that does something different per environment.

You did not write a single catch. You wrote a try with no walls. The framework wraps the whole controller in an implicit try { ... } catch (Throwable $e) { /* render */ } and any exception lands in App\Exceptions\Handler. Some get logged. Some get rendered as JSON. Some get retried by the queue. Some get silently turned into a 404 by the ModelNotFoundException handler Laravel ships out of the box.

Critics have long called exceptions a structured goto for exactly this reason. The control flow leaves your function at any of those eight lines and lands somewhere you did not write. You don't see it in the source. You see it in production when the on-call engineer asks why a charge succeeded but the order row never landed in the database.

The hidden code paths your try/catch was covering

PHP-the-runtime makes this worse than most exception-based languages, and not in obvious ways.

Throwing inside a destructor. PHP's __destruct runs during object cleanup. Throw an exception there and you do not get a normal stack unwind. You get a fatal error, because the engine is already in cleanup and the catch handler that would have swallowed it is gone. Documented bug since 2005 (bug #33598) and it still bites connection pools and lazy-loaded resources today.

Throwing inside a stream wrapper. PHP's streamWrapper interface calls user code from C. An exception that escapes a wrapper method does not propagate through fopen. It becomes a warning, a half-open descriptor, and a false return that your calling code logs as "could not open."

return inside finally. Anything in a finally that returns a value silently swallows the exception that was propagating. The caller gets a clean return value and the original error vanishes. PHP, JavaScript, and Python all share this behavior — documented in each language's spec, and equally surprising in production. Go does not have it.

Empty catch. Search any production PHP codebase for catch (\Throwable $e) {}. You will find it. Maybe with a // TODO. Maybe with a Log::debug filtered out at WARN. The error is gone and you only learn it happened when downstream code receives bad input.

Framework swallowing. Laravel's default handler turns ModelNotFoundException into a 404 for HTTP requests. The HTTP-only path is fine; the trap is that the same findOrFail in a queue worker or scheduled command throws the underlying exception, and depending on how your app's exception handler is configured the not-found can be reported as a benign warning and the job marked successful instead of being retried.

Every one of these is a path you did not write, gated by a try/catch you also did not write. That is the inheritance you bring to Go on day one. Go isn't teaching you new error-handling syntax; it's removing the hidden path.

What if err != nil forces you to consider

Here is the same checkout flow in Go. No framework. Pure stdlib plus a hypothetical stripe and mailer client.

func (h *Handler) Checkout(
    ctx context.Context, userID, cartID int64,
) (*Order, error) {
    user, err := h.users.Get(ctx, userID)
    if err != nil {
        return nil, fmt.Errorf("load user %d: %w", userID, err)
    }

    cart, err := h.carts.Load(ctx, cartID)
    if err != nil {
        return nil, fmt.Errorf("load cart %d: %w", cartID, err)
    }

    total, err := h.pricing.Compute(cart)
    if err != nil {
        return nil, fmt.Errorf("compute total: %w", err)
    }

    charge, err := h.stripe.Charge(ctx, user, total)
    if err != nil {
        return nil, fmt.Errorf("charge user %d: %w", user.ID, err)
    }

    order, err := h.orders.Create(ctx, Order{
        UserID: user.ID,
        Amount: total,
        Charge: charge.ID,
    })
    if err != nil {
        return nil, fmt.Errorf("persist order: %w", err)
    }
Enter fullscreen mode Exit fullscreen mode

The mail step gets a different treatment: the order is already committed and a confirmation email is not worth rolling back a charge for, so a failure becomes a warning instead of a hard return.

    if err := h.mail.Send(ctx, user.Email, OrderConfirmed{order}); err != nil {
        // not fatal — the order is committed, mail is async-safe to retry
        h.log.Warn("send confirmation", "order", order.ID, "err", err)
    }

    return order, nil
}
Enter fullscreen mode Exit fullscreen mode

Six fallible calls. Six error checks. One downgraded to a warning, on purpose, in the source.

In PHP, the question "what happens if the email fails after the charge succeeds?" is something you answer in code review, in a Slack thread, six months later in a postmortem. In Go, you answer it on line 32, today, because the compiler will not let you call a fallible function and ignore the result. The decision is in the source.

A common counterpoint says you can still write _, _ = thing() and Go will accept it. True — but you have to type the underscores. The act of discarding is visible. In an exception-based language the act is the default.

errors.Is, errors.As, and the chain you built on purpose

Once you accept that errors are values, the question becomes: how do callers tell errors apart? Three tools, all in the standard library since Go 1.13.

%w to wrap. fmt.Errorf("load user %d: %w", id, err) produces a new error whose message contains the formatted string, and whose Unwrap() method returns the original. The chain is preserved. The next layer up can wrap again. By the time the error reaches your HTTP handler, you have a breadcrumb trail of every layer that touched it.

errors.Is(err, target) for sentinel matching. Use this when the caller cares that the error equals a known value. The classic example is io.EOF:

data, err := io.ReadAll(r)
if errors.Is(err, io.EOF) {
    // expected, end of stream
    return nil
}
if err != nil {
    return fmt.Errorf("read stream: %w", err)
}
Enter fullscreen mode Exit fullscreen mode

errors.Is walks the wrap chain. If io.EOF was wrapped four layers deep, it still matches.

errors.As(err, &typed) for type matching. Use this when the caller cares about the type of the error and wants to pull data out of it.

var stripeErr *stripe.CardError
if errors.As(err, &stripeErr) {
    return Response{
        Status: 402,
        Body:   stripeErr.DeclineCode,
    }
}
Enter fullscreen mode Exit fullscreen mode

Same chain-walking behavior. The error can be wrapped four times by the time it gets here and the type assertion still works because errors.As unwraps as it goes.

This is the equivalent of catching a specific exception type, except the caller opts in. The wrapping function decided it was OK to expose *stripe.CardError as part of its public contract. Wrap with %v instead of %w and the chain breaks on purpose; errors.As returns false. Sometimes you want exactly that: the underlying error stays free to change without breaking callers. The point is that you choose. PHP has no language-level equivalent of "this exception is internal, do not catch it externally" — @internal docblocks and final exception classes get close, but nothing in the runtime stops a caller from catching what you wanted hidden.

Sentinel errors vs typed errors

Two patterns. Pick the one that matches what the caller needs.

Sentinel is a package-level var ErrNotFound = errors.New("not found"). The caller checks with errors.Is. Use this when the error has no associated data — the existence of the error is the entire signal.

package users

var ErrNotFound = errors.New("user: not found")

func (s *Store) Get(ctx context.Context, id int64) (*User, error) {
    row := s.db.QueryRowContext(ctx, "SELECT ... WHERE id = $1", id)
    var u User
    if err := row.Scan(&u.ID, &u.Email); err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, ErrNotFound
        }
        return nil, fmt.Errorf("scan user %d: %w", id, err)
    }
    return &u, nil
}
Enter fullscreen mode Exit fullscreen mode

Typed is a struct that implements the error interface and carries fields. The caller checks with errors.As. Use this when the error has structured data the caller needs to act on — a status code, a retry-after, a field name.

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation: %s: %s", e.Field, e.Message)
}

// caller side:
var vErr *ValidationError
if errors.As(err, &vErr) {
    return JSONError{
        Field:   vErr.Field,
        Message: vErr.Message,
    }
}
Enter fullscreen mode Exit fullscreen mode

This is the part most ex-PHP devs miss for the longest. PHP has custom exception classes, but they are rarely consulted at the catch site because nobody knows what types the upstream functions throw. In Go, the typed error is part of the function's public surface. It shows up in godoc and in the package's tests. The contract is visible.

When panic is actually appropriate

Go has panic and recover. New Go developers either avoid them entirely or sprinkle them like throw statements. Both extremes are wrong.

Panic is for unrecoverable invariant violations. The short list:

  • Programmer errors. A nil map you wrote m["key"] = v on. An index out of range. The runtime panics on these for you.
  • Must* constructors at init. regexp.MustCompile, template.Must, sql.Register. If this fails the program cannot run — fail loudly at startup, not at request time.
  • Truly unrecoverable state. A goroutine pool detects its own corruption. The choice is panic vs continue with broken state, and continue is worse.

Panic is not for file not found, network timeouts, validation failures, or anything a user input could trigger. If the failure can come from outside the program, it is an error. Catch yourself reaching for panic and ask: would a malicious or unlucky user be able to crash my whole binary by triggering this? If yes, it is an error.

recover only works inside a deferred function in the same goroutine. A panic in go func() will not be caught by a recover in main. The stdlib net/http wraps each request in a recover for you.

Production examples that pay off

Three patterns exception-trained engineers tend to reinvent badly until they see them once.

Wrap at every layer boundary. Domain code returns sentinel or typed errors. Adapters wrap with the operation name. The HTTP handler wraps once more with the request ID. The final log message reads like a sentence: "http /checkout req=abc123: charge user 4421: stripe: card_declined". The chain is the trace.

errors.Is(err, context.Canceled) before logging. Half the "errors" in a long-running service are clients hanging up mid-request. Check for context.Canceled and context.DeadlineExceeded early and demote the log line to debug. PHP has no equivalent because the request lifecycle is not first-class in PHP-FPM.

One error type per fault domain. ErrTransient (retry), ErrPermanent (do not retry), ErrValidation (return to user), ErrAuth (401). Three to five types, used at every boundary. The retry policy is six lines of errors.Is. The HTTP middleware that maps errors to status codes is twelve. There is no 200-line global exception handler with if/elseif on class hierarchies, because the dispatching happens at the call site.

What you actually gain

You give up the cleanliness of an 8-line PHP function for the boringness of a 25-line Go function, and what you get back is concrete. Every place the function can fail is on screen, in order. You named the error variants the caller cares about, so the wrap chain reads as your stack trace. Nothing leaves the function unintentionally — no framework swallows it, no empty catch hides it — and code review walks the failure path as readily as the happy one.

Tomorrow, open one fallible PHP function in your codebase and write the Go version on a notepad next to it. Six checks, seven, ten. Each one an explicit decision. The exercise is short and the muscle is portable: a week later, when you go back to PHP, you will count failure paths in code that no longer makes them obvious, and you will start writing the missing try/catch boundaries you used to leave to the framework.

If this clicked

The first half of The Complete Guide to Go Programming is about exactly this — building the mental model of Go from the ground up, with chapters on errors, panics, the runtime, and the standard library patterns that make if err != nil a tool instead of a punishment. The second book in the series, Hexagonal Architecture in Go, picks up where this leaves off — how typed errors flow across layer boundaries in real systems.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)