Exceptions: Checked vs Unchecked, and Clean Error Handling

Most programs spend most of their life running smoothly. The quality of your code shows up in the other 10% — when files are missing, networks stall, and input is nonsense. Java's exception system is designed to make that 10% explicit instead of accidental. This post gives you the one decision rule that settles almost every "should I throw or catch this?" question, then shows you the modern mechanics.

The hierarchy: what can actually be thrown

Everything throwable in Java sits under one root, Throwable. The tree splits into two families with very different philosophies:

Throwable Error OutOfMemoryError, StackOverflowError Exception IOException, SQLException … RuntimeException unchecked branch checked e.g. IOException don't catch NullPointerException, IllegalArgumentException …
  • Error — things your program can't plausibly recover from (OutOfMemoryError, StackOverflowError). Rule: let these crash. Catching them is almost always wrong.
  • Exception, checked side — conditions a caller can anticipate and recover from (IOException, SQLException). The compiler forces you to handle or declare them.
  • Exception → RuntimeException, unchecked side — programming bugs and invalid usage (NullPointerException, IllegalArgumentException, IndexOutOfBoundsException). The compiler doesn't force you to handle them, because the correct response is usually "fix the code", not "catch it".

The decision rule: checked or unchecked?

Forget memorized lists. Ask one question:

Can the caller plausibly anticipate this and recover?

  • Yes → checked exception. Example: IOException — the file genuinely might not exist, and the caller might retry or fall back.
  • No — it's a bug or invalid usage → unchecked (RuntimeException). Example: IllegalArgumentException — the caller passed a negative age; the fix is in the calling code, not in a catch block.

What breaks if you get it wrong? Make a bug unchecked-worthy situation a checked exception, and you force every caller to handle a condition they can't recover from — boilerplate everywhere. Make a recoverable condition unchecked, and callers never learn they should handle it until production blows up. The rule keeps both failure modes honest.

// Checked: the caller can decide what to do.
void loadConfig(String path) throws IOException {
    // Files.readString may fail — that's a real, foreseeable event.
}

// Unchecked: a bug in the caller. Fix the caller, don't catch it.
void setAge(int age) {
    if (age < 0) {
        throw new IllegalArgumentException("age must be >= 0, got " + age);
    }
}

try / catch / finally

The mechanics: try guards code that may fail, catch handles a specific failure, finally runs no matter what (cleanup). Catch from most specific to most general — the first matching catch wins.

import java.io.IOException;
import java.nio.file.*;

public class CatchOrder {
    public static void main(String[] args) {
        try {
            String text = Files.readString(Path.of("config.txt"));
            System.out.println("Loaded: " + text.length() + " chars");
        } catch (NoSuchFileException e) {
            System.out.println("Config missing, using defaults");
        } catch (IOException e) {
            System.out.println("Could not read config: " + e.getMessage());
        }
    }
}

Output when the file is missing:

Config missing, using defaults

If the catches were reversed, NoSuchFileException (a subclass of IOException) would never be reached — the compiler rejects unreachable catch blocks, which is one place Java actually saves you from your own ordering mistakes.

Multi-catch: one handler, several exceptions

When the recovery is identical, don't write three catch blocks:

try {
    int port = Integer.parseInt(args[0]);
    connect(port);
} catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
    System.out.println("Usage: app <port-number>");
}

Constraint: the exceptions in one multi-catch can't be in a subclass relationship with each other (e.g. IOException | FileNotFoundException won't compile — the general one already covers the specific).

try-with-resources: the default for anything closeable

Any object implementing AutoCloseable (streams, readers, database connections, scanners) should be opened in try-with-resources. Compare the old ceremony with the modern one-liner:

// The old finally-close ceremony — verbose, and easy to get subtly wrong.
BufferedReader reader = null;
try {
    reader = Files.newBufferedReader(Path.of("data.txt"));
    System.out.println(reader.readLine());
} catch (IOException e) {
    System.out.println("read failed: " + e.getMessage());
} finally {
    if (reader != null) {
        try { reader.close(); } catch (IOException ignored) { }
    }
}
// Modern default: the resource closes automatically, in reverse order,
// and any exception from close() is attached as a *suppressed* exception
// on the original — nothing is silently lost.
try (BufferedReader reader = Files.newBufferedReader(Path.of("data.txt"))) {
    System.out.println(reader.readLine());
} catch (IOException e) {
    System.out.println("read failed: " + e.getMessage());
    for (Throwable s : e.getSuppressed()) {
        System.out.println("suppressed: " + s);
    }
}

This is the single biggest error-handling upgrade in modern Java, and it's been the default idiom since Java 7. If you're writing finally { close(); } by hand in new code, stop — that's what this is for. (We'll lean on it heavily in post 18, on I/O.)

Custom exceptions: when they earn their keep

Don't create a custom exception for decoration. Create one when the caller needs to distinguish your failure from everything else — i.e. when catching IllegalArgumentException wouldn't be precise enough.

// Earns its keep: a booking service where "seat taken" needs
// different handling (offer alternatives) than "bad input" (reject).
public class SeatUnavailableException extends RuntimeException {
    private final String seatId;

    public SeatUnavailableException(String seatId) {
        super("Seat " + seatId + " is already booked");
        this.seatId = seatId;
    }

    public SeatUnavailableException(String seatId, Throwable cause) {
        super("Seat " + seatId + " is already booked", cause);
        this.seatId = seatId;
    }

    public String getSeatId() { return seatId; }
}

Two rules packed in there:

  • Extend RuntimeException unless the caller can genuinely recover (apply the decision rule again). Most custom exceptions should be unchecked.
  • Always offer a constructor taking a cause — that's exception chaining.

Exception chaining: never lose the original cause

When you catch a low-level exception and translate it into your domain exception, pass the original as the cause. The stack trace of the original is preserved and printed after "Caused by:".

try {
    reserveInDatabase(seatId);
} catch (SQLException e) {
    // The caller shouldn't know about SQL. But the cause must survive
    // for whoever debugs this at 2am.
    throw new SeatUnavailableException(seatId, e);
}

Without chaining, the SQLException — the one fact that explains why the seat couldn't be booked — vanishes. That's how "works on my machine" mysteries are born.

Anti-patterns: the hall of shame

1. Catching Exception broadly

try {
    processPayment(order);
} catch (Exception e) {          // DON'T
    System.out.println("oops");
}

This catches NullPointerException and IllegalArgumentException — your bugs — alongside the payment failure, then treats them all as "oops". Bugs get hidden instead of fixed. Catch the specific exceptions you can actually handle; let the rest propagate to someone who can see them.

2. Empty catch blocks

try {
    config.load();
} catch (IOException e) { }      // DON'T — the failure now never happened

An empty catch is a lie: the program continues as if nothing failed, and the config is silently missing. If you truly can't do anything, at minimum log it. Better: don't catch what you can't handle.

3. Exceptions for control flow

// DON'T — exceptions are not a loop-exit mechanism.
try {
    while (true) { queue.remove(); }
} catch (NoSuchElementException e) { /* done */ }

Exceptions are expensive (stack traces aren't free) and this reads as a bug to every future reader. Use the API's own signals — queue.poll() returning null, an isEmpty() check, a proper loop condition.

4. throws Exception on everything

public void save(Order o) throws Exception { ... }   // DON'T

This tells the caller exactly nothing: which exceptions? recoverable how? It forces a broad catch at every call site, which is anti-pattern #1 all over again. Declare the specific checked exceptions you can actually throw.

5. Log-and-swallow

try {
    syncToWarehouse(order);
} catch (IOException e) {
    logger.error("sync failed", e);   // logged… and then forgotten
}

Logging is not handling. If the sync matters, the caller needs to know it failed — rethrow, wrap in a domain exception, or return a result the caller must inspect. Log-and-swallow converts a recoverable failure into silent data loss.

Your handling strategy: the checklist

  • Fail fast on bugs. NullPointerException, IllegalArgumentException, bad state — throw unchecked, don't catch. The stack trace is the report; fix the code.
  • Recover on expected conditions. File missing, network timeout, bad user input — catch the specific checked exception (or validate input up front) and do something real: retry, fall back, ask again.
  • Use try-with-resources for everything closeable. No hand-rolled finally-close ceremony in new code.
  • Chain your causes. When translating exceptions across layers, keep the original as the cause.
  • Never silently swallow. Empty catches, broad catches, and log-and-swallow all hide failures. If you catch it, handle it — otherwise let it propagate.
  • Be specific in signatures. Declare exactly which checked exceptions you throw; never throws Exception.

Next up: we put this into practice where exceptions matter most — reading and writing files and streams in post 18, on I/O. Everything after that builds on code that fails cleanly instead of failing mysteriously.

Continue: Java Learning Roadmap 2026

Comments

Popular posts from this blog

JSP Servlet Interview Questions For Freshers Series 1

Java Banking Finance Services and Insurance (BFSI) domain interview questions

Java program to check even or odd number