← All posts
August 12, 2026

Java exceptions: the hierarchy, checked vs unchecked, and writing your own

javaexceptionsbackend

Exception handling is one of those things every Java developer does constantly and almost nobody thinks about deliberately. You wrap something in a try block, catch what the IDE tells you to catch, and move on. That works fine until you're the one designing an API and have to decide: should this be a checked exception or not? Should I even write a custom exception class, or just throw an IllegalArgumentException and call it a day? Those decisions are where the hierarchy actually starts to matter.

The hierarchy, in one picture

Everything throwable in Java descends from a single root, and where a class sits in that tree tells you how it's meant to be used.

Throwable
├── Error                     (JVM-level failures — don't catch these)
│   ├── OutOfMemoryError
│   └── StackOverflowError
└── Exception
    ├── IOException            (checked)
    ├── SQLException           (checked)
    └── RuntimeException       (unchecked)
        ├── NullPointerException
        ├── IllegalArgumentException
        ├── IllegalStateException
        └── IndexOutOfBoundsException

Throwable splits into two very different branches. Error represents problems the JVM itself is in trouble with — running out of memory, a stack that's overflowed — and your code generally has no meaningful way to recover, so you don't catch these. Exception is everything else: application-level problems that your code is expected to anticipate and handle. Almost everything you'll ever throw or catch lives under Exception.

Checked vs unchecked: the distinction that actually matters

Under Exception, there's a second split that trips people up more than the Error distinction does: checked versus unchecked. The difference isn't about severity, it's about whether the compiler forces you to acknowledge the exception.

Checked exceptions — any subclass of Exception that isn't a RuntimeException — must be either caught or declared with throws in the method signature. The compiler won't let you ignore them.

public void readConfig(String path) throws IOException {
    Files.readAllLines(Paths.get(path));
}

Anyone calling readConfig has to deal with IOException — catch it, or declare it themselves and pass the problem further up:

try {
    readConfig("app.properties");
} catch (IOException e) {
    // handle it here, or the caller has to
}

Unchecked exceptions — subclasses of RuntimeException — need no such declaration. They can be thrown from anywhere without the compiler ever asking about them.

public int divide(int a, int b) {
    return a / b; // throws ArithmeticException if b is 0 — no `throws` needed
}

The rule of thumb I actually use: checked exceptions are for conditions a well-written caller can reasonably recover from and should be forced to think about — a missing file, a network call that failed. Unchecked exceptions are for programming errors — a null that shouldn't have been null, an argument that violates a precondition — where forcing every method up the call stack to declare throws just adds noise without adding safety. Most modern Java code, including most libraries written in the last decade, leans heavily toward unchecked exceptions for exactly this reason: checked exceptions look safer on paper, but in practice they mostly train developers to write catch (Exception e) {} and swallow the problem just to get the compiler off their back.

try-with-resources: cleanup without the boilerplate

Before getting to custom exceptions, it's worth mentioning the pattern you'll use around most checked exceptions involving I/O. Anything that implements AutoCloseable can go in a try-with-resources block, and Java guarantees it gets closed even if an exception is thrown partway through:

try (BufferedReader reader = Files.newBufferedReader(path)) {
    return reader.readLine();
} catch (IOException e) {
    throw new ConfigLoadException("Failed to load config from " + path, e);
}

No finally block, no risk of forgetting to close the reader on the error path. That last line — wrapping IOException in a different exception type — is the segue into the real topic.

When a custom exception is worth writing

Not every failure needs its own exception class. If IllegalArgumentException or IllegalStateException already says exactly what went wrong, reusing them is the right call — a custom class that adds nothing but a different name is just ceremony. A custom exception earns its place when at least one of these is true:

  • Callers need to react differently to this failure than to other failures — catch it specifically and do something with it, not just log and rethrow.
  • It needs to carry extra data beyond a message — an error code, the value that failed validation, how far over a limit something was.
  • The name itself is documentation. InsufficientFundsException tells the next engineer what happened at a glance. IllegalStateException("balance too low") makes them read the message to find out.

Writing a custom exception

Here's an unchecked custom exception for a BankAccount.withdraw() call — a programming-adjacent failure that callers might still want to react to specifically, so it carries the shortfall amount as structured data instead of burying it in a string:

public class InsufficientFundsException extends RuntimeException {

    private final double shortfall;

    public InsufficientFundsException(String message, double shortfall) {
        super(message);
        this.shortfall = shortfall;
    }

    public double getShortfall() {
        return shortfall;
    }
}

It extends RuntimeException, so callers aren't forced to catch it — most of the time a failed withdrawal should just propagate up to whatever's returning an error response. Using it inside the class it belongs to:

public class BankAccount {
    private double balance;

    public void withdraw(double amount) {
        if (amount > balance) {
            double shortfall = amount - balance;
            throw new InsufficientFundsException(
                "Cannot withdraw " + amount + ", balance is only " + balance,
                shortfall
            );
        }
        balance -= amount;
    }
}

And catching it where it's actually useful — say, showing the user exactly how much more they need:

try {
    account.withdraw(500);
} catch (InsufficientFundsException e) {
    System.out.println("You're short by $" + e.getShortfall());
}

A checked custom exception, and preserving the cause

Sometimes the failure really does need to force callers to handle it — a checked exception is the right tool when skipping the handling would be a real bug, not just an inconvenience. Here's one for order validation, along with the constructor pattern that matters most: accepting a cause so you never lose the original exception when you wrap it.

public class OrderValidationException extends Exception {

    public OrderValidationException(String message) {
        super(message);
    }

    public OrderValidationException(String message, Throwable cause) {
        super(message, cause);
    }
}

Using the second constructor to wrap a lower-level failure:

public void placeOrder(Order order) throws OrderValidationException {
    try {
        inventoryClient.reserve(order.getItems());
    } catch (SQLException e) {
        throw new OrderValidationException(
            "Could not validate inventory for order " + order.getId(), e
        );
    }
}

That second argument to super(message, cause) is easy to skip and important not to. Without it, the original SQLException — the actual root cause, with its own stack trace — is gone the moment you throw the new exception. Whoever debugs this later sees OrderValidationException: Could not validate inventory for order 4471 and nothing about why the database call failed. With the cause preserved, e.getCause() and the printed stack trace both lead straight back to the real error.

A few rules I hold myself to

  • Never leave a catch block empty. catch (Exception e) {} doesn't make the problem go away, it just makes it invisible until it shows up somewhere much harder to debug.
  • Always pass the cause when wrapping an exception. Losing the original stack trace turns a five-minute debugging session into a much longer one.
  • Don't catch Exception or Throwable broadly unless you're at a genuine top-level boundary (a request handler, a job runner) that has to guarantee it won't crash the process. Catching broadly anywhere else usually just hides bugs you'd rather know about.
  • Don't use exceptions for expected control flow. If "not found" is a normal, common outcome — like looking up an optional value — return an Optional or a result type instead of throwing. Exceptions are for exceptional cases; using them for routine branching makes both the code and the stack traces harder to read.

Getting exception design right isn't about memorizing the hierarchy — it's about a habit of asking, every time you write a throw, whether the exception you're creating tells the next person who sees it what actually went wrong.