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 PracticesAn 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.RuntimeExceptionand its subclasses are unchecked.- All other
Exceptionsubclasses 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, excludingRuntimeException) are verified by the compiler: a method must eithercatchthem or declarethrows. They represent recoverable, expected conditions (IOException,SQLException). - Unchecked exceptions (
RuntimeException/Errorsubclasses) 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:
Errorsignals a serious, usually unrecoverable JVM/system problem —OutOfMemoryError,StackOverflowError,NoClassDefFoundError. Applications should not catch these; there's typically nothing useful to do.Exceptionsignals 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 areturn) — 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.
throwis a statement that actually raises an exception object, now.throwsis 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.