Java Exceptions — Complete Notes

Checked vs unchecked, the hierarchy, try-with-resources, custom exceptions, and the judgment call that actually matters in real code — when to wrap, when to propagate, and when to shut up and let it fly.

00. What an exception actually is

An exception is Java's way of saying "I cannot finish this method, and I refuse to pretend I did." It is a second return channel — one that carries a failure instead of a value, and that you cannot silently ignore.

Before exceptions, languages signalled failure with magic return values: return -1, or null, or set a global error flag. The problem is that nothing forces the caller to look:

The old way — failure as a return value, easily ignored
int result = divide(10, 0);   // returns -1 to mean "error"
System.out.println(result);   // prints -1 and carries on happily
                              // The bug travels silently through your whole program.

An exception flips this around. The failing method stops, and the error travels up the call stack on its own until someone handles it. If nobody does, the program dies loudly with a stack trace — which is far better than quietly computing the wrong answer.

The exception way — impossible to ignore by accident
int divide(int a, int b) {
    if (b == 0) throw new ArithmeticException("cannot divide by zero");
    return a / b;
}

int result = divide(10, 0);   // this line never completes
System.out.println(result);   // never runs — control has already left
The one idea behind all of it

An exception separates the happy path from the error path. Your main logic reads top-to-bottom as if nothing can fail, and the failure handling lives in one place off to the side. Compare that to checking a return code after literally every single call.

What happens when you throw — stack unwinding

When you throw, the JVM abandons the current method and looks for a matching catch in the caller. If there isn't one, it abandons that method too, and keeps going up the call stack. This walk is called stack unwinding. If it reaches the top of the thread with no handler, the thread dies and the trace is printed.

Unwinding — the exception climbs until something catches it
void main()  { a(); }              // 3. no catch here either -> thread dies, trace printed
void a()     { b(); }              // 2. no catch here -> keep climbing
void b()     { throw new IllegalStateException("boom"); }  // 1. thrown here

// Output:
// Exception in thread "main" java.lang.IllegalStateException: boom
//     at Demo.b(Demo.java:8)
//     at Demo.a(Demo.java:6)
//     at Demo.main(Demo.java:4)

Throwing is pulling the emergency cord on a train. The current carriage stops immediately and the alarm passes down the train, carriage by carriage, until someone with a key (a catch) responds. If nobody on the whole train has a key, the train stops dead and a report is printed of exactly which carriage pulled the cord and who was standing behind it.

01. The hierarchy — everything is a Throwable

Every single thing you can throw or catch in Java descends from one class: Throwable. Learn this tree and 80% of exception questions answer themselves.

The tree that explains everything
              Object
                |
            Throwable                 <- the root of everything throwable
             /      \
          Error    Exception
            |        /      \
            |       |    RuntimeException
            |       |          |
            |       |    - NullPointerException
            |       |    - IllegalArgumentException
            |       |    - IllegalStateException
            |       |    - IndexOutOfBoundsException
            |       |    - ArithmeticException
            |       |    - ClassCastException
            |       |
            |    (checked exceptions)
            |     - IOException
            |     - FileNotFoundException
            |     - SQLException
            |     - InterruptedException
            |
        (unrecoverable JVM problems)
         - OutOfMemoryError
         - StackOverflowError
         - NoClassDefFoundError

The tree splits into three practical groups:

Group Who caused it Checked? Should you catch it?
Error The JVM itself is in trouble No No — you cannot meaningfully fix it
RuntimeException A bug in your code No Usually no — fix the bug instead
Other Exception The outside world misbehaved Yes Yes — this is expected and recoverable
The rule that defines "checked"

An exception is checked if it extends Exception but not RuntimeException. That's the whole rule. Error and RuntimeException and all their subclasses are unchecked. Nothing else about the class matters — not its name, not its package.

Never catch Error

OutOfMemoryError means the heap is exhausted; StackOverflowError means the stack is. Catching them and "carrying on" leaves the JVM in a state where nothing is trustworthy — your catch block itself may need memory it can't get. Let the process die and restart it.

02. Checked vs unchecked — the real distinction

Checked exceptions are enforced by the compiler: you must either catch them or declare them with throws. Unchecked exceptions are not — you can ignore them entirely and the code still compiles.

Checked — the compiler will not let you look away
// IOException is checked, so ONE of these two is mandatory:

// Option A — handle it here
void readA() {
    try {
        Files.readString(Path.of("a.txt"));
    } catch (IOException e) {
        System.out.println("could not read: " + e.getMessage());
    }
}

// Option B — declare it and make it the caller's problem
void readB() throws IOException {
    Files.readString(Path.of("a.txt"));
}

// Option C — do neither
void readC() {
    Files.readString(Path.of("a.txt"));   // COMPILE ERROR:
}                                          // unreported exception IOException; must be caught or declared
Unchecked — the compiler says nothing at all
void parse(String s) {
    int n = Integer.parseInt(s);   // throws NumberFormatException (unchecked)
}                                  // Compiles fine. No try, no throws. Blows up at runtime if s is "abc".

Why the distinction exists

The intended meaning is a statement about whose fault it is and whether the caller can do anything:

Checked Unchecked
Means "Something outside your control failed" "You have a bug"
Example The network dropped, the file is gone You passed null, index was -1
Caller can react? Yes — retry, use a default, tell the user No — there's nothing sensible to do
Right fix Handle it Change the code

Checked is the restaurant telling you "we're out of salmon" — annoying, but expected, and you can just order something else. Unchecked is the waiter tripping and dropping the plate — that isn't a menu decision you respond to, it's a mistake that should be fixed in the kitchen.

The honest modern take

Checked exceptions are the most argued-about feature in Java, and it is worth knowing why so you can answer with nuance rather than dogma. In practice they have two well-known failure modes:

Failure mode 1 — the swallow, the single worst thing you can do
try {
    riskyThing();
} catch (IOException e) {
    // nothing here. "I'll deal with it later."
}
// The failure is now invisible. No log, no trace, no clue.
// Debugging this at 3am is genuinely miserable.
Failure mode 2 — throws pollution creeping up every layer
String load()   throws IOException, SQLException, ParseException { ... }
String service() throws IOException, SQLException, ParseException { return load(); }
String handler() throws IOException, SQLException, ParseException { return service(); }
// Every layer is forced to name low-level failures it has no interest in.
What modern codebases actually do

Most modern Java (and every language designed after Java — C#, Kotlin, Go, Scala) leans unchecked. The common convention: use unchecked for programming errors and for failures the caller genuinely can't fix, and reserve checked for the rare case where a caller has a realistic, specific recovery. When crossing a layer boundary, wrap the checked exception in a meaningful unchecked one (see section 07).

03. try / catch / finally — the mechanics

try marks code that might fail, catch handles a specific failure, and finally runs cleanup no matter what happens.

The full shape
try {
    // code that might throw
} catch (FileNotFoundException e) {
    // runs ONLY for this type (or a subclass of it)
} catch (IOException e) {
    // runs for other IOExceptions
} finally {
    // ALWAYS runs — exception or not, return or not
}

Catch order matters — most specific first

Java picks the first catch whose type matches. So a broad catch placed above a narrow one makes the narrow one unreachable — and that is a compile error, not a warning.

Wrong order — the compiler rejects it outright
try {
    read();
} catch (IOException e) {           // broad
} catch (FileNotFoundException e) { // COMPILE ERROR:
}                                   // exception FileNotFoundException has already been caught

// Correct: subclass first, superclass second.
try {
    read();
} catch (FileNotFoundException e) { // specific
} catch (IOException e) {           // general fallback
}

Multi-catch (Java 7+)

One block, several unrelated types
try {
    process();
} catch (IOException | SQLException e) {     // handle both the same way
    log.error("processing failed", e);
    throw new ProcessingException(e);
}
Two rules for multi-catch

1. The types must not be related by inheritancecatch (IOException | FileNotFoundException e) is a compile error, because the second is already covered by the first. 2. The variable e is implicitly final — you cannot reassign it inside the block.

finally — and the three ways it can betray you

finally runs on the way out no matter how you leave the try: normal completion, an exception, or even a return.

finally runs even when you return
int f() {
    try {
        return 1;        // value 1 is computed and held...
    } finally {
        System.out.println("cleanup");   // ...but this runs BEFORE the method actually returns
    }
}
// prints "cleanup", then returns 1
Never return (or throw) from finally

A return in finally discards the exception that was travelling and overrides the real return value. It is the most effective way to make a bug invisible. Most linters flag it; treat it as banned.

The trap — a swallowed exception
int bad() {
    try {
        throw new RuntimeException("the real problem");
    } finally {
        return 42;      // the exception is thrown away. Caller sees 42 and thinks all is well.
    }
}
// bad() returns 42. The RuntimeException vanishes without trace.
When finally does NOT run
// 1. The JVM exits
try { System.exit(0); } finally { System.out.println("never printed"); }

// 2. The thread is killed, or the JVM crashes / is SIGKILLed
// 3. An infinite loop or deadlock inside try means you never leave it

04. try-with-resources — the only way to close things

try-with-resources (Java 7+) automatically closes anything you open. It replaces the entire class of "I forgot to close the file" bugs, and it handles an edge case that hand-written finally almost always gets wrong.

The old way — verbose, and subtly broken
BufferedReader br = null;
try {
    br = new BufferedReader(new FileReader("a.txt"));
    System.out.println(br.readLine());
} catch (IOException e) {
    log.error("read failed", e);
} finally {
    if (br != null) {
        try { br.close(); }          // close() itself throws IOException, so another try...
        catch (IOException ignored) {}   // ...and now you're swallowing again
    }
}
The modern way — same behaviour, plus a fix you didn't know you needed
try (BufferedReader br = new BufferedReader(new FileReader("a.txt"))) {
    System.out.println(br.readLine());
} catch (IOException e) {
    log.error("read failed", e);
}
// br.close() is called automatically, guaranteed, even if readLine() throws.

How it works

Anything declared in the try (...) header must implement AutoCloseable (or Closeable, which extends it). The compiler generates the finally that calls close() for you.

Your own closeable resource
class Connection implements AutoCloseable {
    Connection() { System.out.println("open"); }
    void use()   { System.out.println("using"); }
    @Override public void close() { System.out.println("closed"); }  // narrowed: throws nothing
}

try (Connection c = new Connection()) {
    c.use();
}
// open
// using
// closed
Resources close in REVERSE order of declaration

Just like nested finally blocks would. The last thing opened is the first thing closed — which is exactly what you want, since later resources usually depend on earlier ones.

Multiple resources — opened left to right, closed right to left
try (Connection c = new Connection();
     Statement s = c.createStatement();
     ResultSet r = s.executeQuery(sql)) {
    // use r
}
// close order: r, then s, then c
// Note: the semicolon after the LAST resource is optional.

Suppressed exceptions — the edge case that matters

Here is the problem the old finally style silently got wrong. Suppose the body throws, and then close() also throws. You now have two exceptions and only one can propagate. Which one wins?

The body's exception wins. Always.

That's the right answer — the body's failure is the real problem; the close failure is usually a consequence of it. But the close exception is not thrown away: it is attached to the primary one and retrievable via getSuppressed(). Hand-written finally does the opposite — the close exception replaces and destroys the original.

Suppression in action
class Bad implements AutoCloseable {
    void use()  { throw new RuntimeException("primary — the real bug"); }
    public void close() { throw new RuntimeException("secondary — from close()"); }
}

try (Bad b = new Bad()) {
    b.use();
} catch (Exception e) {
    System.out.println(e.getMessage());                    // primary — the real bug
    System.out.println(e.getSuppressed()[0].getMessage()); // secondary — from close()
}
Why the old style loses information
Bad b = new Bad();
try {
    b.use();               // throws "primary" ...
} finally {
    b.close();             // ... then this throws "secondary", REPLACING it entirely.
}
// Caller sees only "secondary". The actual bug is gone forever.
Effectively-final resources (Java 9+)

Before Java 9 you had to declare the resource inside the parentheses. Since Java 9 you can use an existing variable, provided it is final or effectively final: try (br) { ... }.

05. throw vs throws, and propagation

One letter apart, completely different jobs. throw is an action; throws is a declaration.

throw throws
What it is A statement — actually raises it now Part of the method signature
Where Inside a method body After the parameter list
How many One object at a time A comma-separated list of types
Means "Failing, right now" "This might fail — caller beware"
Both together
void withdraw(int amount) throws InsufficientFundsException {   // declaration
    if (amount > balance) {
        throw new InsufficientFundsException("need " + amount + ", have " + balance);  // action
    }
    balance -= amount;
}

Overriding rules — a subclass may never widen the contract

If you override a method, you cannot declare new or broader checked exceptions than the parent. If you could, code holding a parent reference would be blindsided by an exception it was never told about — breaking the Liskov substitution principle.

What an override may and may not declare
class Parent {
    void go() throws IOException { }
}

class Child extends Parent {
    // ALLOWED — same exception
    void go() throws IOException { }
}
class Child2 extends Parent {
    // ALLOWED — narrower (FileNotFoundException extends IOException)
    void go() throws FileNotFoundException { }
}
class Child3 extends Parent {
    // ALLOWED — fewer: declaring nothing at all is always fine
    void go() { }
}
class Child4 extends Parent {
    // COMPILE ERROR — broader. Exception is a supertype of IOException.
    void go() throws Exception { }
}
class Child5 extends Parent {
    // COMPILE ERROR — brand new checked exception the parent never mentioned
    void go() throws SQLException { }
}
class Child6 extends Parent {
    // ALLOWED — unchecked exceptions are never part of the contract, declare freely
    void go() throws IllegalStateException { }
}

06. Custom exceptions

Write your own exception when the failure is meaningful in your domain and a caller might want to catch that specific thing. Not before.

The standard shape — always provide the cause constructor
// Unchecked: extend RuntimeException (the usual default)
public class InsufficientFundsException extends RuntimeException {

    private final BigDecimal shortfall;   // carry USEFUL data, not just a string

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

    public InsufficientFundsException(String message, Throwable cause) {
        super(message, cause);            // NEVER drop the cause
        this.shortfall = null;
    }

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

    public BigDecimal getShortfall() { return shortfall; }
}

// Checked: extend Exception instead — everything else is identical
public class ConfigurationException extends Exception {
    public ConfigurationException(String message, Throwable cause) { super(message, cause); }
}
Checked or unchecked — the deciding question

Ask: "can the caller realistically do something different if they catch this?" If yes (retry, fall back, prompt the user) → consider checked. If it just means "someone made a mistake" or "this is broken and nothing will help" → unchecked. When unsure, choose unchecked; it's the modern default and far easier to change later.

Use the built-ins when they fit

Don't invent MyNullException. Java already has precise, universally understood names: IllegalArgumentException (bad input), IllegalStateException (wrong time to call this), UnsupportedOperationException (not implemented / not allowed), NullPointerException (use Objects.requireNonNull).

Validation with the standard exceptions
void setAge(int age) {
    if (age < 0) throw new IllegalArgumentException("age must be >= 0, got: " + age);
    this.age = age;
}

void send() {
    if (!connected) throw new IllegalStateException("call connect() before send()");
    ...
}

Order(Customer c) {
    // throws NPE with a clear message instead of a mystery NPE three calls later
    this.customer = Objects.requireNonNull(c, "customer must not be null");
}
Put real facts in the message

"Invalid input" is worthless at 3am. "age must be >= 0, got: -5" tells you the rule and the actual value. Include the values — never include passwords, tokens or personal data.

07. Wrap vs propagate — the design judgment

This is the part that separates people who "know the syntax" from people who write good Java. There are exactly four things you can do with an exception, and choosing correctly is the whole skill.

Option Do it when…
1. Handle it You can genuinely fix or recover here — retry, use a cached value, use a default
2. Propagate The caller is in a better position to decide. The default for most methods.
3. Wrap and rethrow You are crossing a layer boundary and the low-level type would leak
4. Swallow Almost never. Only when the failure is truly irrelevant — and log it anyway.

Propagate — say nothing, do nothing

If you can't add value, get out of the way. The worst code catches, logs, and rethrows at every single level, producing the same stack trace six times in the log.

Just let it fly
String read(Path p) throws IOException {
    return Files.readString(p);   // nothing useful to add here — let the caller deal with it
}
The log-and-rethrow anti-pattern

catch (X e) { log.error("failed", e); throw e; } at every layer means one failure prints five near-identical stack traces, and it becomes genuinely hard to tell whether you had one problem or five. Log once, at the boundary where you actually handle it.

Wrap — translate at a layer boundary

Your UserService shouldn't throw SQLException. That leaks the fact that you use a database — the caller can't act on it, and the day you switch to a REST API, every caller breaks. Wrap it in something meaningful for your layer.

Exception translation — hide the mechanism, keep the evidence
public User findUser(long id) {
    try {
        return jdbc.query("SELECT ...", id);
    } catch (SQLException e) {
        // Translate to MY layer's vocabulary — but keep `e` as the cause!
        throw new UserLookupException("could not load user " + id, e);
    }
}
The single most important rule in this entire document

Always pass the original exception as the cause. new MyException(msg, e) — never new MyException(msg). Drop the cause and you destroy the stack trace pointing at the actual line that failed, leaving you with a useless "could not load user 7" and no idea why.

What the cause buys you — the "Caused by" chain
UserLookupException: could not load user 7
    at UserService.findUser(UserService.java:22)
    at UserController.get(UserController.java:14)
Caused by: java.sql.SQLException: connection refused        <- the actual reason
    at org.postgresql.Driver.connect(Driver.java:198)
    ... 12 more
✓ Do
  • Throw at the level of abstraction the caller understands.
  • Always chain the cause: new X("msg", e).
  • Catch the narrowest type you can actually handle.
  • Handle (and log) once, at a real boundary — a controller, a job runner, main.
  • Include the offending values in the message.
✗ Don't
  • Swallow: catch (Exception e) {}.
  • catch (Exception e) or catch (Throwable t) as a blanket.
  • Log and rethrow at every layer.
  • return or throw from finally.
  • Use exceptions for normal control flow (see gotchas).
  • Drop the cause when wrapping.
Catching Exception at the very top is fine

The blanket ban has one exception: the outermost boundary. A web framework's error handler, a scheduled job's loop, or main should catch broadly so that one bad request doesn't kill the server. That's a deliberate safety net, not laziness — and it logs.

08. Reading a stack trace

A stack trace is not noise. It tells you what failed, where, and how you got there — read it top-down and you rarely need a debugger.

Anatomy of a trace
Exception in thread "main" java.lang.NullPointerException: Cannot invoke
    "String.length()" because "name" is null                 <- (1) WHAT + why (helpful NPE, Java 14+)
    at com.app.User.nameLength(User.java:42)                 <- (2) WHERE it was thrown — start here
    at com.app.UserService.process(UserService.java:18)      <- (3) who called it
    at com.app.Main.main(Main.java:7)                        <- (4) the entry point
Caused by: java.sql.SQLException: connection refused         <- (5) the ROOT cause — often the real story
    at org.postgresql.Driver.connect(Driver.java:198)
    ... 12 more                                              <- (6) frames identical to the trace above
How to actually read one

1. Scroll to the bottom — the last Caused by is usually the real problem. 2. Then read the topmost line of your own package in that block — library frames are rarely the bug. 3. The exception type and message tell you what invariant broke.

Helpful NullPointerExceptions (Java 14+)

Modern Java tells you which reference was null: Cannot invoke "String.length()" because "name" is null. On older versions you'd get a bare NullPointerException and a line with five possible culprits. This alone is a strong reason to be on a recent JDK.

09. Gotchas — where Java surprises you

1. Exceptions are expensive — but not for the reason you think.

The cost is almost entirely in fillInStackTrace(), which walks the whole stack when the exception is constructed (not when thrown or caught). A try block that doesn't throw costs essentially nothing. This is why using exceptions for normal control flow — like ending a loop — is slow as well as unreadable.

2. catch (Exception e) does not catch everything.

It misses Error (e.g. OutOfMemoryError, StackOverflowError). Only catch (Throwable t) catches literally everything — and you should not.

3. finally overrides the try's return value.

If finally has its own return, it wins and any in-flight exception is discarded. But note the subtlety: finally mutating a primitive local after return x does not change the returned value — the value was already copied. Mutating an object's fields does show up, because the reference was copied, not the object.

4. Swallowing InterruptedException breaks cancellation.

Catching it clears the thread's interrupt flag. If you don't rethrow it, at minimum restore the flag with Thread.currentThread().interrupt() — otherwise the code above you never learns it was asked to stop, and your shutdown hangs.

5. Lambdas and streams cannot throw checked exceptions.

Function<T,R>.apply declares no checked exceptions, so list.forEach(f -> Files.readString(f)) won't compile. You must catch inside the lambda and wrap in an unchecked exception (or use a plain for loop). This friction is a big part of why modern Java drifted toward unchecked.

6. A constructor that throws leaves you with no object — but its resources may leak.

If a constructor throws partway, the object is never handed to you and becomes garbage. Anything it already opened is now unreachable and unclosed. Open resources last, or clean up on the way out.

7. e.printStackTrace() is not logging.

It writes to System.err, bypassing your log framework — so it has no timestamp, no level, no correlation id, and in production it's very often routed straight to /dev/null. Use log.error("context", e).

10. Interview Q&A

Q: Checked vs unchecked — what's the actual rule?

Unchecked = RuntimeException and Error and their subclasses. Checked = everything else under Exception. Checked exceptions must be caught or declared with throws; the compiler enforces it. The intent: checked for recoverable, external problems; unchecked for programming bugs.

Q: Will finally always run?

Practically always — including when try returns or throws. It won't run if System.exit() is called, the JVM crashes or is killed, or the thread never leaves the try (infinite loop/deadlock).

Q: What's the point of try-with-resources over finally?

Less code, impossible to forget, closes in reverse order — and crucially it gets suppressed exceptions right. If the body and close() both throw, the body's exception propagates and the close exception is attached via getSuppressed(). Hand-written finally loses the original entirely.

Q: throw vs throws?

throw is a statement that raises one exception object now. throws is a signature clause declaring which checked exceptions a method may emit.

Q: Can an overriding method throw a broader checked exception?

No. It may throw the same, a subclass, fewer, or none. It may declare any unchecked exceptions freely, since those aren't part of the contract. Widening would break callers holding a supertype reference.

Q: Why shouldn't you catch Exception or Throwable?

You catch things you never anticipated — including bugs like NullPointerException and, with Throwable, OutOfMemoryError — and then carry on as if fine. Catch the narrowest type you can genuinely handle. The one legitimate exception is a top-level boundary handler that logs and keeps the process alive.

Q: What is exception chaining and why does it matter?

Passing the original as the cause: new MyException("msg", e). It preserves the original stack trace under Caused by:, so you keep the real point of failure while presenting a meaningful type to your callers. Without it, wrapping destroys the evidence.

Q: Are exceptions slow?

Building one is — fillInStackTrace() walks the stack at construction time. Entering a try that doesn't throw is effectively free. So: fine for exceptional cases, genuinely bad for control flow in a hot loop.

Q: What happens if an exception is thrown inside a catch block?

It propagates normally — sibling catch blocks of the same try do not get a chance at it. The finally still runs, and if finally throws too, its exception replaces the one from the catch block.

11. Cheat sheet

  • Hierarchy: ThrowableError (don't catch) + ExceptionRuntimeException (unchecked) + the rest (checked).
  • Checked rule: extends Exception but not RuntimeException → must catch or declare.
  • Meaning: checked = the world broke (recoverable) · unchecked = you broke it (fix the code).
  • Catch order: most specific first — broad-before-narrow is a compile error.
  • Multi-catch: catch (A | B e) — unrelated types only, e is final.
  • finally: always runs (except System.exit/crash). Never return or throw from it.
  • try-with-resources: needs AutoCloseable · closes in reverse order · body's exception wins, close's is getSuppressed().
  • throw = do it now (statement) · throws = might happen (signature).
  • Override: same / narrower / fewer / none — never broader checked.
  • Wrapping: always chain the cause — new X("msg", e).
  • Built-ins: IllegalArgumentException (bad input) · IllegalStateException (bad timing) · UnsupportedOperationException (not allowed) · Objects.requireNonNull (null checks).
  • Never: swallow · printStackTrace() · log-and-rethrow at every layer · exceptions as control flow.
  • Reading a trace: bottom-most Caused by first, then the top frame in your own package.
Last reviewed · July 2026 · part of knowledge-base