Exception Handling Interview Questions & Answers

33 questions Updated 2026-06-18

Java exception handling interview questions — checked vs unchecked, the exception hierarchy, try/catch/finally, try-with-resources, custom exceptions, chaining, and common pitfalls like swallowing exceptions.

Read the in-depth guideJava Exception Handling — Checked vs Unchecked, try/catch & Best Practices

An exception is an object representing an abnormal event that disrupts normal flow — a division by zero, a missing file, a null dereference. When one occurs, the JVM creates an exception object and propagates it up the call stack until a matching catch handles it, or the thread terminates.

try {
  int x = 10 / 0;          // throws ArithmeticException
} catch (ArithmeticException e) {
  System.out.println("Caught: " + e.getMessage()); // "/ by zero"
}

Exceptions separate error-handling code from the main logic and carry context (message + stack trace) to help diagnose what went wrong.

Everything throwable descends from Throwable, which splits into two branches:

  • Error — serious problems the application shouldn't catch (OutOfMemoryError, StackOverflowError). Unchecked.
  • Exception — conditions a program may want to handle.
    • RuntimeException and its subclasses are unchecked.
    • All other Exception subclasses are checked.
Throwable
├── Error              (unchecked — don't catch)
└── Exception
    ├── RuntimeException   (unchecked)
    └── IOException, SQLException... (checked)

So "checked vs unchecked" is purely about whether the class sits under RuntimeException/Error (unchecked) or elsewhere under Exception (checked).

  • Checked exceptions (subclasses of Exception, excluding RuntimeException) are verified by the compiler: a method must either catch them or declare throws. They represent recoverable, expected conditions (IOException, SQLException).
  • Unchecked exceptions (RuntimeException/Error subclasses) are not compiler-enforced — usually programming bugs (NullPointerException, IllegalArgumentException, ArrayIndexOutOfBoundsException).
void read() throws IOException {     // checked -> must declare or catch
  Files.readString(Path.of("x"));
}
void bug() {
  String s = null;
  s.length();                        // unchecked -> no declaration required
}

Guideline: checked for conditions callers can reasonably recover from; unchecked for bugs and contract violations.

Both extend Throwable, but:

  • Error signals a serious, usually unrecoverable JVM/system problem — OutOfMemoryError, StackOverflowError, NoClassDefFoundError. Applications should not catch these; there's typically nothing useful to do.
  • Exception signals an application-level condition that code may reasonably handle.
try {
  recurse();                  // infinite recursion
} catch (StackOverflowError e) {
  // technically possible, but a code smell — don't rely on this
}

Both are unchecked (well, Error is), but the intent differs: Error = "the platform is broken"; Exception = "your program hit a handleable situation."

  • try — wraps code that might throw.
  • catch — handles a specific exception type (you can have several).
  • finally — runs always, whether or not an exception occurred (and even after a return) — for cleanup like closing resources.
try {
  risky();
} catch (IOException e) {
  log(e);
} finally {
  cleanup();   // runs no matter what
}

A try needs at least one catch or a finally. catch blocks are checked top-down, so order subclasses before superclasses.

finally runs after the try/catch in virtually all cases — including when the try or catch executes a return, break, or continue. The only ways to skip it: System.exit(), the JVM crashing, an infinite loop/deadlock in the try, or the thread being killed.

int f() {
  try {
    return 1;     // evaluated...
  } finally {
    System.out.println("still runs"); // ...but finally runs before returning
  }
}

Beware: a return (or throw) inside finally overrides the try's return and swallows pending exceptions — a notorious bug, so never return/throw from finally.

A try with resources declared in parentheses auto-closes them when the block exits — normally or via exception — as long as they implement AutoCloseable. It replaces verbose finally { x.close(); } blocks and avoids leaks.

try (var br = Files.newBufferedReader(path);
     var conn = dataSource.getConnection()) {
  return br.readLine();
}   // br and conn closed automatically, in reverse order

Resources close in reverse order of declaration. It also handles the case where both the body and close() throw — see suppressed exceptions.

AutoCloseable is the interface (one method, close()) that makes a class eligible for try-with-resources. Implement it for any resource that needs deterministic cleanup — connections, streams, locks.

class Connection implements AutoCloseable {
  Connection() { System.out.println("open"); }
  public void close() { System.out.println("closed"); }
}
try (Connection c = new Connection()) {
  // use c
} // close() called automatically

Closeable (from java.io) extends AutoCloseable but narrows close() to throw IOException. Prefer AutoCloseable for general resources.

Multi-catch (Java 7+) handles several exception types in one catch using the | separator, when the handling logic is identical — reducing duplication.

try {
  parseAndStore();
} catch (IOException | SQLException e) {   // one handler for both
  log.error("operation failed", e);
  throw new ServiceException(e);
}

Rules: the types must not be subclasses of one another (redundant), and the caught variable e is implicitly final. Use it to collapse copy-pasted catch blocks.

  • throw is a statement that actually raises an exception object, now.
  • throws is a method declaration clause announcing which checked exceptions the method may propagate to its caller.
void withdraw(int amt) throws InsufficientFundsException { // declares
  if (amt > balance) {
    throw new InsufficientFundsException(); // raises
  }
}

Mnemonic: throw does it (one object), throws warns about it (a list of types). You can throw unchecked exceptions without declaring them.

Extend Exception (for checked) or RuntimeException (for unchecked), and provide constructors that pass the message and cause to super.

public class InsufficientFundsException extends RuntimeException {
  private final int shortfall;
  public InsufficientFundsException(int shortfall) {
    super("Short by " + shortfall + " cents");
    this.shortfall = shortfall;
  }
  public int getShortfall() { return shortfall; }
}

Custom exceptions let you carry domain-specific data and let callers catch precisely. Prefer unchecked for programming/business errors unless the caller can genuinely recover (then checked).

Exception chaining wraps a low-level exception inside a higher-level one while preserving the original as the "cause", so the full diagnostic trail survives. Pass the cause to the constructor or initCause.

try {
  jdbc.query();
} catch (SQLException e) {
  throw new DataAccessException("load failed", e); // e becomes the cause
}

The printed stack trace shows Caused by: java.sql.SQLException.... Chaining lets you translate exceptions across abstraction layers without losing the root cause — far better than catching and re-throwing a bare new exception.

Swallowing is catching an exception and doing nothing (or only an empty block), hiding the failure so the program continues in a possibly broken state and you lose all diagnostic information.

// swallowed — silent failure, impossible to debug
try { risky(); } catch (Exception e) { }

// at minimum, log it (and preserve the stack trace)
try { risky(); } catch (Exception e) {
  log.error("risky() failed", e);
  throw e;                 // or wrap/rethrow if you can't handle it
}

Rule: never have an empty catch. Either handle it meaningfully, log it, or rethrow. Empty catches are one of the most damaging anti-patterns in production code.

catch blocks are evaluated top to bottom, and the first matching type wins. So a more specific exception must come before its superclass — otherwise the broad catch shadows the specific one and the compiler reports "exception has already been caught."

try {
  read();
} catch (FileNotFoundException e) {  // specific first
  // handle missing file
} catch (IOException e) {            // broader after
  // handle other I/O errors
}

Reversing them (IOException first) is a compile error, because FileNotFoundException could never be reached.

An NPE happens when you dereference null — call a method, access a field, index an array, or unbox a null wrapper.

String s = map.get("missing"); // null
s.length();                    // NullPointerException

Prevention: validate inputs (Objects.requireNonNull), use Optional for maybe-absent values, prefer constants on the left of equals ("x".equals(s)), use getOrDefault, and embrace Java 14+ helpful NPE messages which name the exact expression that was null (Cannot invoke "String.length()" because "s" is null).

Optional<T> is a container that explicitly represents "value or absent," forcing callers to deal with the empty case instead of risking an NPE on a surprise null.

Optional<User> user = repo.findById(id);
String name = user.map(User::name)
                  .orElse("unknown");        // default if absent
user.ifPresent(u -> sendEmail(u));           // act only if present

Best practices: use it as a return type for "might not find one"; don't use it for fields, parameters, or collections (return an empty collection instead); and avoid .get() without checking (isPresent/orElseThrow).

A stack trace is the snapshot of the call stack at the moment an exception was created — it lists, top to bottom, the method calls from where the exception was thrown back up to the entry point.

java.lang.NullPointerException: ... "user" is null
    at com.app.Service.process(Service.java:42)   <- where it was thrown
    at com.app.Controller.handle(Controller.java:18)
    at com.app.Main.main(Main.java:9)
Caused by: java.sql.SQLException: ...             <- original cause

Read the top frame for where it blew up, and follow Caused by: down to the root cause. Print it with e.printStackTrace() or, better, log.error(msg, e).

A return in finally overrides any return or exception from the try/ catch — the try's pending result (or thrown exception) is discarded silently. This is a subtle, dangerous bug.

int f() {
  try {
    return 1;
  } finally {
    return 2;   // method returns 2; the "return 1" is lost
  }
}
int g() {
  try {
    throw new RuntimeException();
  } finally {
    return 0;   // swallows the exception entirely
  }
}

Never put return/throw/break in finally. Use finally strictly for cleanup, not control flow.

In try-with-resources, if the body throws and close() also throws, Java keeps the body's exception as primary and attaches the close exception as a suppressed exception (so neither is lost). You retrieve them via getSuppressed().

try (AutoCloseable r = () -> { throw new IOException("close failed"); }) {
  throw new RuntimeException("body failed"); // primary
}
// RuntimeException propagates; the IOException is in getSuppressed()

Before try-with-resources, a close() exception in a manual finally would replace the original — hiding the real cause. Suppression fixes that. The printout shows Suppressed: beneath the main trace.

  • Rethrow as-is — let it propagate after partial handling (log, then throw e).
  • Wrap and rethrow — translate to a higher-level type, preserving the cause (throw new ServiceException(e)).
  • Rethrow a different type — only when it improves the abstraction.
try {
  repo.save(order);
} catch (SQLException e) {
  metrics.increment("save.failure");
  throw new PersistenceException("could not save order", e); // wrap + chain
}

Always pass the original as the cause when wrapping, so debugging info survives. Don't catch just to rethrow the same type with no added value.

  • Catch specific exceptions, not bare Exception/Throwable.
  • Never swallow — handle, log, or rethrow.
  • Throw early, catch late — validate inputs at the boundary; handle where you have context to recover.
  • Don't use exceptions for control flow (they're expensive and obscure intent).
  • Preserve the cause when wrapping.
  • Clean up with try-with-resources, not manual finally.
  • Include useful messages with context.
// specific, contextual, chained
catch (IOException e) {
  throw new ConfigException("failed to load " + path, e);
}

The main cost is capturing the stack trace when the exception is constructed — the JVM walks the entire call stack and records each frame. The throw/catch mechanism itself is cheap; building the trace is not. So using exceptions for ordinary control flow (e.g. loop termination) is slow.

// for hot paths where the trace isn't needed, you can disable it:
class FastException extends RuntimeException {
  FastException() { super(null, null, false, false); } // no stack trace
}

Guidance: reserve exceptions for exceptional conditions; use return values, Optional, or sentinels for expected outcomes. Don't catch-and-ignore in tight loops.

Decide by whether the caller can reasonably recover:

  • Checked (extends Exception) — recoverable, expected conditions the caller should be forced to handle (e.g. FileNotFoundException-like cases).
  • Unchecked (extends RuntimeException) — programming errors, contract violations, or failures the caller usually can't fix (validation, configuration, most business-rule violations).
// recoverable -> checked
class RetryableNetworkException extends Exception { }
// bug / unrecoverable -> unchecked
class InvalidConfigException extends RuntimeException { }

Modern frameworks (Spring, etc.) lean heavily toward unchecked to avoid throws clutter, translating low-level checked exceptions into runtime ones.

Catching Throwable also catches Errors (OutOfMemoryError, StackOverflowError) that you can't sensibly recover from and may make things worse. Catching Exception broadly hides bugs (an unexpected NullPointerException gets swallowed by a handler meant for I/O errors).

// too broad — masks programming bugs and serious errors
try { work(); } catch (Throwable t) { /* keep going */ }

// catch what you can actually handle
try { work(); } catch (IOException e) { recover(e); }

Catch the narrowest type that you genuinely know how to handle; let everything else propagate to a top-level handler.

Exception translation means converting low-level exceptions into ones appropriate to the current abstraction layer, so callers aren't coupled to implementation details (a service shouldn't leak SQLException).

// persistence layer hides JDBC details from the service layer
try {
  return jdbcTemplate.query(sql);
} catch (SQLException e) {
  throw new RepositoryException("query failed", e); // translate + chain
}

Always keep the original as the cause. This is exactly what Spring's DataAccessException hierarchy does — translating vendor-specific SQL errors into a consistent, unchecked API.

assert checks an invariant that should always be true; if false it throws an AssertionError. Assertions are disabled by default at runtime (enable with the -ea JVM flag), so they're for catching developer bugs during development/testing, not validating production input.

assert index >= 0 : "index must be non-negative, got " + index;

Because they can be turned off, never use assert for argument validation or anything with side effects — use if (...) throw new IllegalArgumentException(...) for real input checks.

A try can contain another try. If an inner block doesn't catch an exception, it propagates outward to the nearest enclosing handler — and keeps unwinding the stack until something catches it or the thread dies.

try {
  try {
    throw new IllegalStateException("inner");
  } finally {
    System.out.println("inner finally"); // runs during unwind
  }
} catch (IllegalStateException e) {
  System.out.println("caught in outer"); // handled here
}

During propagation, every finally along the way still executes. This unwinding is how an exception thrown deep in the call stack reaches a top-level handler.

If an exception propagates out of a thread's run/main without being caught, the thread terminates, and the JVM hands the exception to that thread's UncaughtExceptionHandler (by default printing the stack trace to System.err). Other threads keep running; the whole JVM only exits if it was the last non-daemon thread.

Thread.setDefaultUncaughtExceptionHandler((t, e) ->
    log.error("Uncaught in " + t.getName(), e));

Setting a handler is essential for background threads, whose failures would otherwise vanish silently.

Both are standard unchecked exceptions for precondition violations:

  • IllegalArgumentException — a method argument is invalid (wrong value/range).
  • IllegalStateException — the object is in the wrong state for the operation, regardless of the arguments.
void setAge(int age) {
  if (age < 0) throw new IllegalArgumentException("age < 0: " + age);
}
void start() {
  if (running) throw new IllegalStateException("already started");
}

Use the built-in exceptions (plus NullPointerException for null args, idiomatically via Objects.requireNonNull) rather than inventing custom ones for these common cases.

Manual cleanup in finally is verbose and easy to get wrong: you must null-check, nest try/catch around close(), and handle the case where close() itself throws (which would hide the original exception). Multiple resources multiply the boilerplate.

// error-prone manual style
BufferedReader br = null;
try {
  br = Files.newBufferedReader(path);
} finally {
  if (br != null) br.close(); // close() can throw and mask the real error
}

// correct, concise, handles suppression
try (var br = Files.newBufferedReader(path)) { }

Try-with-resources closes in the right order, handles nulls, and records suppressed exceptions — strictly better.

Since Java 7, the compiler analyzes which checked exceptions can actually flow out of a try, so you can catch a broad type but rethrow with a narrower throws clause — as long as the caught variable is effectively final.

void m() throws IOException, SQLException {  // precise, not "throws Exception"
  try {
    if (cond) throw new IOException();
    else throw new SQLException();
  } catch (Exception e) {   // catch broadly...
    log(e);
    throw e;                // ...rethrow: compiler knows it's IOException|SQLException
  }
}

Before Java 7 you'd have been forced to declare throws Exception. This lets you log centrally while keeping precise method signatures.

If a static initializer (or static field initialization) throws, the JVM wraps it in an ExceptionInInitializerError and the class fails to initialize. Worse, the class is marked unusable: any later attempt to use it throws NoClassDefFoundError.

class Config {
  static final int VALUE = compute(); // if compute() throws...
  static int compute() { throw new RuntimeException("boom"); }
}
// First use -> ExceptionInInitializerError
// Subsequent uses -> NoClassDefFoundError

This is a confusing failure mode in real systems — a misconfigured static constant can make a class permanently unloadable. Keep static initialization simple and defensive.

Doing both at every level causes "log spam" — the same exception printed repeatedly as it propagates. The common rule: either handle it (and log) OR propagate it (and let a higher layer log) — not both at every level.

// log-and-throw at every layer -> duplicated stack traces
catch (IOException e) { log.error("failed", e); throw e; }

// propagate now, log once at the top-level boundary
catch (IOException e) { throw new ServiceException(e); }
// ... and a single handler at the controller/edge logs it

Log once, at the boundary where the exception is finally handled (e.g. a controller advice or a top-level catch), with full context.

Practice tests are coming soon

Get notified when interactive mock interviews and quizzes launch.