What is the difference between checked and unchecked exceptions?

Asked constantlyintermediate0–8 yrs10 min readJava 7Java 8LTSJava 9Java 14

Checked exceptions are enforced by the compiler; unchecked ones are not. The interesting question is not the definition but why every major Java framework since 2004 has moved to unchecked exceptions — and lambdas finished the argument in Java 8.

The Answer

Say this in the room. 45 seconds.

  • Checked: extends Exception but not RuntimeException. The compiler forces you to catch it or declare throws. IOException, SQLException.
  • Unchecked: extends RuntimeException. No compiler enforcement. NullPointerException, IllegalArgumentException, IllegalStateException.
  • Error is a third category — OutOfMemoryError, StackOverflowError. Unchecked, and not yours to catch.
  • The intended rule: checked for conditions a caller can plausibly recover from, unchecked for programming mistakes.
  • The practice: nearly every modern framework uses unchecked exceptions. Spring wraps SQLException into unchecked DataAccessException; JPA and Hibernate throw unchecked.
  • Java 8 settled it. No standard functional interface declares throws, so a checked exception cannot escape a lambda — which makes checked exceptions incompatible with streams.

Understand It

The hierarchy, and the one line that defines everything

Throwable
├── Error                 unchecked — JVM is broken, don't catch
├── Exception             CHECKED
│   ├── IOException
│   ├── SQLException
│   └── RuntimeException  unchecked — the exception to the rule
│       ├── NullPointerException
│       ├── IllegalArgumentException
│       └── IllegalStateException
└── ...

Everything under Exception is checked except the RuntimeException subtree. That single carve-out is the whole mechanism. There is no keyword and no annotation — checkedness is decided purely by where you sit in that hierarchy, which is why extends RuntimeException is the only decision you make when writing a custom exception.

The argument that was actually lost

The design intent was sound: make the caller acknowledge failures they could handle. Two things went wrong in practice.

It doesn't compose. A method that calls three libraries collects three unrelated checked types. You either declare all of them, and they become part of your signature forever, or you collapse to throws Exception, which discards the information the feature exists to provide. Change a private implementation detail — swap a file for an HTTP call — and a checked exception change ripples through every caller up the stack, even though nothing about the contract changed.

So people silence them. The predictable outcome is the single worst construct in Java:

try {
    doSomething();
} catch (IOException e) {
    // TODO
}

A feature that was meant to force handling instead trained a generation to write empty catch blocks. C# looked at this and deliberately shipped without checked exceptions. Kotlin, running on the same JVM with the same libraries, has none either.

The frameworks voted the same way. Spring's JdbcTemplate catches SQLException and rethrows unchecked DataAccessException, with an explicit rationale: you can't meaningfully recover from most SQL errors at the call site, so forcing every caller to handle them produces noise, not resilience. Hibernate moved to unchecked in 3.0. JPA is unchecked by specification.

Java 8 ended the debate structurally

This is the part that turns an opinion into a constraint. Look at Function:

@FunctionalInterface
public interface Function<T, R> {
    R apply(T t);          // no throws clause
}

No throws. So this does not compile:

List<String> paths = List.of("a.txt", "b.txt");
paths.stream()
     .map(p -> Files.readString(Path.of(p)))   // IOException — won't compile
     .toList();

Files.readString throws IOException; Function.apply cannot. Your options are all bad: wrap every lambda body in try/catch that rethrows unchecked, write your own throwing functional interface and adapters for it, or pull a helper method out just to hold the try/catch.

Whatever you pick, the checked exception becomes unchecked at the lambda boundary. Since streams and lambdas are now how Java is written, checked exceptions lost by construction, not by argument. Any API designed after 2014 that wants to be usable from a stream throws unchecked.

The Java 7 change that mattered more than the syntax

try-with-resources gets sold as "closes the resource for you". The important half is what happens when both the body and close() fail. Watch the difference:

Compiled and run on this build
System.out.println("--- try-with-resources (Java 7+) ---");
try {
    withTryWithResources();
} catch (Exception e) {
    System.out.println("caught     : " + e.getClass().getSimpleName() + ": " + e.getMessage());
    for (Throwable s : e.getSuppressed()) {
        System.out.println("suppressed : " + s.getClass().getSimpleName() + ": " + s.getMessage());
    }
}

System.out.println("--- hand-written finally (pre-7 style) ---");
try {
    withHandWrittenFinally();
} catch (Exception e) {
    System.out.println("caught     : " + e.getClass().getSimpleName() + ": " + e.getMessage());
    System.out.println("suppressed : " + e.getSuppressed().length + " (the real cause is gone)");
}
Output
--- try-with-resources (Java 7+) ---
caught     : IOException: query failed
suppressed : IllegalStateException: close failed: db
--- hand-written finally (pre-7 style) ---
caught     : IllegalStateException: close failed: db
suppressed : 0 (the real cause is gone)

Read those two caught lines again. The query failed in both runs. The pre-7 version reports a close failure and the IOException is destroyed — not logged, not chained, gone. You are debugging an incident with the wrong exception in your hands.

That is what suppressed exceptions fixed, and it's the answer to "why should I use try-with-resources when I can write finally?" The resource management is convenience. Not losing the cause is correctness.

finally can still eat your exception

One construct still discards exceptions silently, and the compiler allows it:

Compiled and run on this build
System.out.println("returnInsideFinally() = " + returnInsideFinally());
System.out.println("...and the RuntimeException it threw? Gone.");
Output
returnInsideFinally() = 42
...and the RuntimeException it threw? Gone.

A return inside finally overrides everything in flight — including a pending exception. So does a break, a continue, or throwing a new exception from finally. Never put a control-flow statement in a finally block. Most static analysers flag it; javac only warns with lint enabled.

Helpful NPEs, and the flag that decides them

NullPointerException is the unchecked exception you'll actually meet. Since Java 14 it can tell you which expression was null:

Compiled and run on this build
String greeting = null;
try {
    greeting.trim();
} catch (NullPointerException e) {
    System.out.println(e.getMessage());
}

Map<String, List<String>> index = new HashMap<>();
try {
    System.out.print(index.get("missing").get(0));
} catch (NullPointerException e) {
    System.out.println(e.getMessage());
}
Output
Cannot invoke "String.trim()" because "greeting" is null
Cannot invoke "java.util.List.get(int)" because the return value of "java.util.Map.get(Object)" is null

The second message is the one that saves hours: in a chained call it names which link returned null. Before Java 14 you got java.lang.NullPointerException and a line number, and on a line with three calls you reached for a debugger.

One catch worth knowing, because it's why this sometimes doesn't work for you: local variable names only appear if the class was compiled with debug information. Without -g, that first message reads because "<local1>" is null. Maven and Gradle both include debug info by default, so you normally get names — but a stripped production build loses them.


Interviewer's Next Move

The question after the question. This is what the round is actually testing.

1. "Which is better, checked or unchecked?" Unchecked, for almost all new code, and the industry has already decided this. Checked exceptions don't compose — three libraries give you three unrelated types — so callers either propagate everything or swallow it. Spring wraps SQLException in unchecked DataAccessException; Hibernate went unchecked in 3.0; C# and Kotlin have no checked exceptions at all. Checked exceptions are defensible for a genuinely recoverable, local condition the caller must decide about.

2. "What did Java 8 change about this?" It ended the debate mechanically. No standard functional interface declares throws, so a checked exception cannot escape a lambda. .map(p -> Files.readString(p)) does not compile. Every workaround converts the checked exception to unchecked at the lambda boundary, so any API meant to be used from a stream throws unchecked.

3. "Why prefer try-with-resources over finally, beyond brevity?" Suppressed exceptions. In a hand-written finally, a close() that throws replaces the exception from the body, destroying the real cause. try-with-resources keeps the body's exception as primary and attaches the close failure via getSuppressed(). That's a correctness difference, not a style one.

4. "Can finally swallow an exception?" Yes — a return, break, or continue inside finally discards an in-flight exception, as does throwing from finally. try { throw ... } finally { return 42; } returns 42 and the exception is gone. Never put control flow in finally.

5. "Should you catch Error, or Throwable?" No. Error means the JVM is in a state you cannot fix — OutOfMemoryError, StackOverflowError. Catching Throwable catches those plus InterruptedException-driven control flow and every programming bug, which converts crashes into silent corruption. The one legitimate use is a top-level handler in a thread or request loop that logs and then rethrows or exits.

6. "How do you write a custom exception?" Extend RuntimeException unless you have a specific reason not to, always provide a constructor taking a cause, and carry structured fields rather than formatting everything into the message. Overriding fillInStackTrace to skip stack capture is a real optimisation for control-flow exceptions on hot paths — and a smell that you're using exceptions for control flow.

7. "What happens if you catch InterruptedException and do nothing?" You've discarded a cancellation signal and cleared the thread's interrupt flag, so nothing upstream can tell the thread was asked to stop — thread pools stop shutting down cleanly. Either propagate it, or call Thread.currentThread().interrupt() to restore the flag before returning.

Code traps

Trap A — predict before you run:

static int f() {
    int x = 1;
    try {
        return x;
    } finally {
        x = 2;
    }
}
Answer

1. The return value is evaluated and copied before finally runs, so mutating the local afterwards has no effect on what was already returned. Change it to return x; inside the finally and you get 2 — and you've also silently made the method swallow exceptions.

Trap B:

try {
    throw new IllegalStateException("first");
} catch (IllegalStateException e) {
    throw new IllegalArgumentException("second");
} finally {
    System.out.println("finally ran");
}
Answer

Prints finally ran, then propagates IllegalArgumentException: second. finally runs even when the catch block itself throws, and the new exception replaces the old one with no chaining — "first" is lost unless you pass it as the cause: new IllegalArgumentException("second", e). Forgetting that cause argument is how stack traces end up starting nowhere near the real problem.

Common wrong answers

Said in interviewsReality
"Checked exceptions are best practice."Every major framework since 2004 moved away from them.
"try-with-resources is just shorter."It preserves the original exception; finally can destroy it.
"You can throw checked exceptions from a lambda."No standard functional interface declares throws.
"NullPointerException is checked."Unchecked — it's a RuntimeException.
"catch (Throwable) is the safe option."It catches OutOfMemoryError and every bug, converting crashes into corruption.
"finally always runs, so exceptions are safe there."It runs, and a return in it discards the exception.

Check Yourself

Q1. What single fact determines whether an exception is checked?

AnswerWhether it sits under RuntimeException (or Error). Everything else below Exception is checked. No keyword, no annotation — position in the hierarchy alone.

Q2. Body throws IOException, close() throws IllegalStateException. What does the caller see, with and without try-with-resources?

AnswerWith: IOException as primary, IllegalStateException reachable via getSuppressed(). Without: only the IllegalStateException — the IOException is gone entirely, which is why the pre-7 pattern produced undiagnosable failures.

Q3. Why does the arrival of lambdas count as an argument about checked exceptions?

AnswerBecause functional interfaces declare no throws, so a checked exception physically cannot leave a lambda body. Every workaround wraps it into an unchecked exception. Once streams became the normal way to write Java, any API wanting to be stream-friendly had to throw unchecked — the language settled the design debate for everyone.


Practice

What changed, and when

Version boundaries are where follow-ups live. Know the change and the behaviour it replaced.

  1. Java 7

    try-with-resources, plus suppressed exceptions: if close() throws while an exception is already in flight, the original survives and the close failure is attached to it.

    Before Java 7 (now gone): You closed in a finally block. If close() threw, its exception replaced the real one entirely — the actual cause was destroyed, and this was the single most common source of undiagnosable production failures in Java.

  2. Java 7

    Multi-catch (catch (A | B e)) and precise rethrow, so a method can rethrow the specific checked types it caught rather than declaring throws Exception.

    Before Java 7: Duplicated catch bodies, or one catch (Exception e) that swallowed types you never meant to handle.

  3. Java 8LTS

    Lambdas made checked exceptions structurally unusable: no built-in functional interface declares throws, so a checked exception cannot escape a lambda body.

    Before Java 8: Anonymous inner classes had the same constraint, but nobody wrote enough of them to care. Streams put this in front of every Java developer at once, and it is why new APIs stopped using checked exceptions.

  4. Java 9

    try-with-resources accepts an existing effectively-final variable, so you can write try (conn) { }.

    Before Java 9: You had to redeclare the resource inside the parentheses, even when you already held it.

  5. Java 14

    Helpful NullPointerException messages name the exact expression that was null (JEP 358) — including which link of a chained call. Opt-in via -XX:+ShowCodeDetailsInExceptionMessages in 14, on by default from 15.

    Before Java 14 (now gone): 'java.lang.NullPointerException' and a line number. On a line like a.b().c().d() you could not tell which of three calls returned null without a debugger or extra logging.

Practice ladder

Reading this page is not knowing it. Four tiers, ending in a real incident.

Where this question goes next

  • try with resources — not written yet
  • custom exception design — not written yet
  • optional vs null — not written yet
  • stream exception handling — not written yet

Every runnable example above was compiled and executed against openjdk 21.0.11 on this build, and its output diffed against what this page claims. Last updated 2026-08-24.