Skip to main content
Java · lesson 27 of 66
Open the Java page →
Java intermediate Lesson 27 of 66

Exception Handling in Java

Learn how Java handles errors — the exception hierarchy, try/catch/finally, checked vs unchecked exceptions, throw/throws, and creating custom exceptions.

By SkillByExample · Updated

An exception is an object that interrupts normal flow and travels up the call stack until a catch handles it — or until it reaches the top and the program exits with a stack trace. This lesson covers the handling side: try/catch/finally, throw and throws, resources, custom exceptions and chaining. (For what each kind of error is, see Error Types in Java.) Every example is one file run with the JDK 21 source launcher (java File.java).

try / catch / finally: the order things run

public class Flow {
    public static void main(String[] args) {
        run("10");
        run("ten");
    }

    static void run(String input) {
        try {
            System.out.println("try: parsing " + input);
            int n = Integer.parseInt(input);
            System.out.println("try: parsed " + n);
        } catch (NumberFormatException e) {
            System.out.println("catch: " + e.getMessage());
        } finally {
            System.out.println("finally");
        }
        System.out.println("after the try statement");
    }
}
$ java Flow.java
try: parsing 10
try: parsed 10
finally
after the try statement
try: parsing ten
catch: For input string: "ten"
finally
after the try statement

When parseInt throws, the rest of the try block (“parsed”) is skipped and control jumps to the matching catch. finally runs on both paths, and because the exception was handled, execution continues normally afterwards.

Several catch blocks: most specific first

The first catch whose type matches wins. Putting a superclass before its subclass makes the subclass block unreachable, and the compiler rejects it.

public class CatchOrder {
    public static void main(String[] args) {
        try {
            Integer.parseInt("x");
        } catch (IllegalArgumentException e) {
            System.out.println("bad argument");
        } catch (NumberFormatException e) {
            System.out.println("bad number");
        }
    }
}
$ java CatchOrder.java
CatchOrder.java:7: error: exception NumberFormatException has already been caught
        } catch (NumberFormatException e) {
          ^
1 error
error: compilation failed

NumberFormatException extends IllegalArgumentException, so the first block already catches it. Swap the two blocks and the program prints bad number. When two unrelated types need the same handling, combine them with multi-catch: catch (IOException | SQLException e).

finally overrides return

finally runs even after a return in try — and if finally itself returns, it replaces the original result and discards any exception.

public class FinallyReturn {
    public static void main(String[] args) {
        System.out.println(normal());
        System.out.println(overridden());
        System.out.println(swallowed());
    }

    static String normal() {
        try { return "from try"; }
        finally { System.out.println("finally ran"); }
    }

    @SuppressWarnings("finally")
    static String overridden() {
        try { return "from try"; }
        finally { return "from finally"; }
    }

    @SuppressWarnings("finally")
    static String swallowed() {
        try { throw new IllegalStateException("lost"); }
        finally { return "no exception?"; }
    }
}
$ java FinallyReturn.java
finally ran
from try
from finally
no exception?

The IllegalStateException in swallowed() vanished without a trace. Use finally for cleanup only, never for return or throw; javac flags it with -Xlint:finally, which the annotations here silence for the demonstration.

throw, throws and custom exceptions

A custom exception carries domain data the caller can act on. Extending Exception makes it checked, so throws must declare it and every caller must handle it.

public class Bank {
    public static void main(String[] args) {
        Account acc = new Account(100);
        try {
            acc.withdraw(30);
            acc.withdraw(150);
            System.out.println("not reached");
        } catch (InsufficientFundsException e) {
            System.out.println(e.getMessage());
            System.out.printf("shortfall: %.2f%n", e.shortfall());
        }
    }
}

class InsufficientFundsException extends Exception {
    private final double requested, available;
    InsufficientFundsException(double requested, double available) {
        super(String.format("cannot withdraw %.2f, balance is %.2f", requested, available));
        this.requested = requested;
        this.available = available;
    }
    double shortfall() { return requested - available; }
}

class Account {
    private double balance;
    Account(double balance) { this.balance = balance; }
    void withdraw(double amt) throws InsufficientFundsException {
        if (amt <= 0) throw new IllegalArgumentException("amount must be positive: " + amt);
        if (amt > balance) throw new InsufficientFundsException(amt, balance);
        balance -= amt;
    }
}
$ java Bank.java
cannot withdraw 150.00, balance is 70.00
shortfall: 80.00

throw raises an exception; throws in the signature declares one may escape. The negative-amount check uses the unchecked IllegalArgumentException — that’s a caller bug, not a situation to recover from, so it needs no declaration.

try-with-resources

Any AutoCloseable declared in the try (...) header is closed automatically, in reverse order, whether the block finishes or throws.

public class Resources {
    public static void main(String[] args) {
        try (Res a = new Res("db"); Res b = new Res("file")) {
            System.out.println("working with " + a.name + " and " + b.name);
            throw new IllegalStateException("work failed");
        } catch (IllegalStateException e) {
            System.out.println("caught: " + e.getMessage());
        }
    }
}

class Res implements AutoCloseable {
    final String name;
    Res(String name) { this.name = name; System.out.println("open " + name); }
    public void close() { System.out.println("close " + name); }
}
$ java Resources.java
open db
open file
working with db and file
close file
close db
caught: work failed

Both resources were closed — file first, since it was opened last — before the catch ran. The equivalent hand-written finally needs null checks and nested trys to be equally safe.

Suppressed exceptions

If the body throws and close() throws too, the body’s exception wins and the close failure is attached to it rather than lost.

public class Suppressed {
    public static void main(String[] args) {
        try (Flaky f = new Flaky()) {
            throw new IllegalStateException("body failed");
        } catch (IllegalStateException e) {
            System.out.println("main: " + e.getMessage());
            for (Throwable s : e.getSuppressed()) System.out.println("suppressed: " + s.getMessage());
        }
    }
}

class Flaky implements AutoCloseable {
    public void close() { throw new IllegalStateException("close failed"); }
}
$ java Suppressed.java
main: body failed
suppressed: close failed

With a plain finally { f.close(); }, the close failure would have replaced “body failed” — hiding the original problem.

Chaining: wrap but keep the cause

When translating a low-level exception into a domain one, pass the original as the cause.

public class Chaining {
    public static void main(String[] args) {
        try {
            loadPort("port=eighty");
        } catch (ConfigException e) {
            System.out.println(e.getMessage());
            System.out.println("cause: " + e.getCause());
        }
    }

    static int loadPort(String line) throws ConfigException {
        try {
            return Integer.parseInt(line.substring(line.indexOf('=') + 1));
        } catch (NumberFormatException e) {
            throw new ConfigException("invalid port in '" + line + "'", e);
        }
    }
}

class ConfigException extends Exception {
    ConfigException(String message, Throwable cause) { super(message, cause); }
}
$ java Chaining.java
invalid port in 'port=eighty'
cause: java.lang.NumberFormatException: For input string: "eighty"

Dropping e from the constructor call compiles fine but throws away the only clue to what actually went wrong; an uncaught ConfigException would then print no Caused by: section.

Best practices

  • Catch the most specific type you can actually handle; let the rest propagate.
  • Never leave a catch block empty — handle, log, or rethrow.
  • Use checked exceptions for conditions callers can recover from, unchecked for bugs.
  • Close resources with try-with-resources, not finally.
  • Wrap with a cause; don’t replace one exception with another and lose the first.
  • Don’t use exceptions for ordinary control flow — check the condition instead where you can.

Practice

  1. Write safeDivide(String a, String b) that returns the quotient, and handles bad numbers and division by zero with different messages. Test "10","2", "10","x" and "10","0".
Solution
public class SafeDivide {
    public static void main(String[] args) {
        System.out.println(safeDivide("10", "2"));
        System.out.println(safeDivide("10", "x"));
        System.out.println(safeDivide("10", "0"));
    }

    static String safeDivide(String a, String b) {
        try {
            return "result " + Integer.parseInt(a) / Integer.parseInt(b);
        } catch (NumberFormatException e) {
            return "not a number: " + e.getMessage();
        } catch (ArithmeticException e) {
            return "cannot divide by zero";
        }
    }
}
$ java SafeDivide.java
result 5
not a number: For input string: "x"
cannot divide by zero

The two catch blocks are unrelated types, so their order doesn’t matter here.

  1. parseAge below swallows the exception: try { return Integer.parseInt(s); } catch (NumberFormatException e) { } return 0; Change it so the failure is reported and the caller still learns something went wrong.
Solution
public class NoSwallow {
    public static void main(String[] args) {
        try {
            System.out.println(parseAge("forty"));
        } catch (IllegalArgumentException e) {
            System.out.println("caller sees: " + e.getMessage() + " (cause: " + e.getCause().getClass().getSimpleName() + ")");
        }
    }

    // was: try { return Integer.parseInt(s); } catch (NumberFormatException e) { } return 0;
    static int parseAge(String s) {
        try {
            return Integer.parseInt(s);
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException("age must be a whole number: " + s, e);
        }
    }
}
$ java NoSwallow.java
caller sees: age must be a whole number: forty (cause: NumberFormatException)

Returning 0 would have produced a person aged zero with no clue why. Rethrowing with context keeps the failure visible and the cause attached.

  1. Use try-with-resources with three Res objects and an exception thrown in the body. Predict the close order and whether catch or close happens first.
Solution
public class ThreeResources {
    public static void main(String[] args) {
        try (Res a = new Res("A"); Res b = new Res("B"); Res c = new Res("C")) {
            throw new RuntimeException("boom");
        } catch (RuntimeException e) {
            System.out.println("catch " + e.getMessage());
        } finally {
            System.out.println("finally");
        }
    }
}

class Res implements AutoCloseable {
    private final String name;
    Res(String name) { this.name = name; }
    public void close() { System.out.println("close " + name); }
}
$ java ThreeResources.java
close C
close B
close A
catch boom
finally

Resources close in reverse order of opening, all before catch; finally comes last.

  1. Make InsufficientFundsException unchecked by extending RuntimeException. What changes in Account.withdraw, and what can callers now forget?
Solution
public class Unchecked {
    public static void main(String[] args) {
        Account acc = new Account(100);
        acc.withdraw(150);              // compiles with no try/catch
        System.out.println("not reached");
    }
}

class InsufficientFundsException extends RuntimeException {
    InsufficientFundsException(String msg) { super(msg); }
}

class Account {
    private double balance;
    Account(double b) { balance = b; }
    void withdraw(double amt) {         // no throws clause needed
        if (amt > balance) throw new InsufficientFundsException("balance is " + balance);
        balance -= amt;
    }
}
$ java Unchecked.java
Exception in thread "main" InsufficientFundsException: balance is 100.0
	at Account.withdraw(Unchecked.java:17)
	at Unchecked.main(Unchecked.java:4)

The throws clause disappears and the compiler no longer reminds callers to handle it, so this one crashed the program. That is the trade-off: checked for expected business failures, unchecked for bugs.

Frequently Asked Questions

What is the difference between checked and unchecked exceptions?
Checked exceptions (like IOException, SQLException) must be declared with throws or caught — the compiler enforces this. Unchecked exceptions (RuntimeException subclasses like NullPointerException, ArrayIndexOutOfBoundsException) don't require declaration. Use checked exceptions for recoverable conditions the caller should handle; use unchecked for programming errors.
When should I use throw vs throws?
throw (no s) is a statement that actually raises an exception: throw new IllegalArgumentException(). throws (with s) is part of a method signature declaring that the method may propagate a checked exception: public void readFile() throws IOException.
Should I catch Exception or RuntimeException as a catch-all?
Avoid it. Catching broad exceptions swallows bugs you don't know about. Catch the most specific exception type you can handle meaningfully. If you must catch broadly (at a top-level handler), log the full stack trace and re-throw or fail fast.
What is a suppressed exception in Java?
When the body of a try-with-resources block throws and closing a resource then throws as well, Java keeps the body's exception as the main one and attaches the close failure to it as a suppressed exception. You can read them with getSuppressed(), and stack traces print them under 'Suppressed:'.