Exception Handling

July 4, 20265 min readUpdated 8/20/2026

An exception is Java's way of saying "I cannot continue, and here is exactly why". Handling them well is mostly about restraint: catching only what you can actually do something about, and never hiding what you cannot.

try, catch, finally

void parse(String input) {
    try {
        int value = Integer.parseInt(input);
        System.out.println(value * 2);
    } catch (NumberFormatException e) {
        System.out.println("not a number: " + input);
    } finally {
        System.out.println("this runs either way");
    }
}

The try block holds code that might fail. The catch runs only if the named exception is thrown. The finally runs whichever happens — including when the try returns early — which is why it was traditionally used for cleanup.

You can catch several types, most specific first, or combine them:

void handle(String input) {
    try {
        System.out.println(Integer.parseInt(input.strip()));
    } catch (NumberFormatException | NullPointerException e) {
        System.out.println("bad input: " + e.getMessage());
    }
}

Order matters: a catch for a parent type placed before a child type makes the child unreachable, and the compiler rejects it.

try-with-resources

Anything holding a file handle, socket or connection must be closed. Doing that in finally is verbose and easy to get wrong, so do not:

void read(Path path) throws IOException {
    try (BufferedReader reader = Files.newBufferedReader(path)) {
        System.out.println(reader.readLine());
    }   // closed automatically, even if an exception was thrown
}

Any resource declared in the parentheses is closed automatically, in reverse order, before any catch runs. It works for anything implementing AutoCloseable, including your own classes. Use it for every resource — there is no case where the manual finally version is better.

Checked versus unchecked

This split is unique to Java and it is the part people argue about.

CheckedUnchecked
ExtendsExceptionRuntimeException
Compilerforces you to catch or declare itignores it
Means"this can fail for reasons outside your control""someone made a programming mistake"
ExamplesIOException, SQLExceptionNullPointerException, IllegalArgumentException
// Checked: the compiler will not let you ignore this
void mustHandle(Path path) throws IOException {      // declare it...
    Files.readString(path);
}

void orCatchIt(Path path) {
    try {                                            // ...or handle it
        Files.readString(path);
    } catch (IOException e) {
        System.out.println("could not read: " + e.getMessage());
    }
}

// Unchecked: no declaration needed, no compiler involvement
void mayThrow(int divisor) {
    System.out.println(10 / divisor);                // ArithmeticException if 0
}

The practical guidance: throw unchecked exceptions for programming errors — a null argument, an invalid state, a number that should have been positive. Reserve checked exceptions for genuinely recoverable conditions where the caller has a real decision to make.

Throwing your own

The next two snippets are lifted from the console bank app this site uses for examples, so they are real working code rather than an illustration. First, a base class for everything the bank itself can reject:

public class BankException extends RuntimeException {

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

    public BankException(String message, Throwable cause) {
        // Always pass the cause along. Dropping it is how a stack trace loses the line that
        // actually broke.
        super(message, cause);
    }
}

Then a specific one that carries data, not just a sentence:

public class InsufficientFundsException extends BankException {

    private final BigDecimal requested;
    private final BigDecimal available;

    public InsufficientFundsException(BigDecimal requested, BigDecimal available) {
        super("Insufficient funds: you asked for %s but only %s is available."
                .formatted(Money.format(requested), Money.format(available)));
        this.requested = requested;
        this.available = available;
    }

    public BigDecimal shortfall() {
        return requested.subtract(available);
    }
}

Two things that make an exception useful, and this class does both. Put the values in the message — "you asked for $200.00 but only $150.00 is available" ends the conversation; "insufficient funds" starts one. And carry the data as fields when the caller needs to act on it rather than just print it, which is what shortfall() is for.

Note the hierarchy. A caller that wants to handle any bank rule catches BankException; one that specifically cares about a shortfall catches InsufficientFundsException. Both work, because the second is a subtype of the first.

Before writing a custom exception, check whether a standard one fits. IllegalArgumentException, IllegalStateException and UnsupportedOperationException cover most cases and every Java developer already knows them.

The four ways to handle one badly

1. Swallowing it. The worst thing in this post:

void terrible(String input) {
    try {
        Integer.parseInt(input);
    } catch (NumberFormatException e) {
        // nothing here
    }
}

The failure happened, the evidence is destroyed, and the program continues with wrong data. An empty catch is almost never right. If an exception genuinely is expected and ignorable, say so in a comment — the comment is what distinguishes a decision from an oversight.

2. Catching Exception to be safe. It also catches the bugs you needed to see, and everything below it in the hierarchy.

3. Losing the cause. When you wrap an exception, pass the original in:

class DataAccessException extends RuntimeException {
    DataAccessException(String message, Throwable cause) {
        super(message, cause);              // keep the original
    }
}

class Repo {
    void load(Path path) {
        try {
            Files.readString(path);
        } catch (IOException e) {
            // throw new DataAccessException("load failed");        // stack trace lost
            throw new DataAccessException("load failed for " + path, e);   // "Caused by:" preserved
        }
    }
}

Dropping the cause is how you end up with a stack trace that points at your wrapper and says nothing about the actual failure. Debugging shows what "Caused by" gives you.

4. Using exceptions for control flow. They are expensive — building a stack trace is real work — and they hide the logic. If a condition is normal, test for it with an if.

Where to catch

The most useful rule in the post: catch an exception only where you can do something about it. Logging and rethrowing at every level produces the same error five times in the log and helps nobody.

In practice that means most methods declare or propagate, and one place near the top — a request handler, a job runner, main — catches, records it once with full detail, and turns it into whatever the caller should see.

class Runner {
    void run() {
        try {
            doWork();
        } catch (Exception e) {                 // the ONE place this is reasonable
            System.err.println("job failed: " + e.getMessage());
            e.printStackTrace();
        }
    }

    void doWork() { }
}

Next

That completes the fundamentals. The next section covers the functional style Java 8 introduced, starting with Lambda Expressions.