Java Checked vs Unchecked Exceptions
Java Checked vs Unchecked Exceptions
Java is the only major mainstream language that distinguishes between exceptions the compiler forces you to handle and exceptions it does not. That distinction — checked versus unchecked — is not a stylistic choice. It is a design contract built into the language: checked exceptions say "this failure is part of this method's normal operation contract, and every caller must have a plan." Unchecked exceptions say "this is a programming mistake or system failure — handle it at the boundary, not everywhere."
Getting this distinction wrong leads to two common problems: throws declarations that spread across an entire codebase for exceptions nobody can recover from, and silent swallowing of exceptions because catching them was made mandatory and a blank catch block was the path of least resistance.
What Are Checked and Unchecked Exceptions?
The classification comes purely from position in the class hierarchy. The Java compiler checks this position when compiling every method call.
CLASSIFICATION RULE — based on class hierarchy:
Extends RuntimeException (directly or transitively)?
YES → UNCHECKED — compiler does NOT require handling or declaration
NO, but extends Exception?
YES → CHECKED — compiler REQUIRES: catch it OR declare throws
Extends Error?
→ Also unchecked — compiler does not require handling
java.lang.Throwable
|
+── Error UNCHECKED (JVM failures — almost never catch)
|
+── Exception
|
+── RuntimeException UNCHECKED (programming errors — fix the code)
| NullPointerException
| IllegalArgumentException
| IndexOutOfBoundsException
| ClassCastException
| UnsupportedOperationException
| ConcurrentModificationException
|
+── IOException CHECKED (caller must handle or declare)
+── SQLException CHECKED
+── ClassNotFoundException CHECKED
+── InterruptedException CHECKED
THE COMPILER RULE IN PRACTICE:
If you call a method that declares: throws IOException
The compiler requires you to either:
OPTION A — handle it:
try { readFile(); } catch (IOException e) { ... }
OPTION B — propagate it:
public void myMethod() throws IOException { readFile(); }
Any other approach: COMPILE ERROR
Basic Overview — Side-by-Side
FEATURE CHECKED UNCHECKED
────────────────────────────────────────────────────────────────────────
Hierarchy root Exception (not via RuntimeEx.) RuntimeException
Compiler enforcement REQUIRED: catch or declare throws No enforcement
Typical cause Expected external failure Programming error
File missing, DB down, timeout Null pointer, bad arg
Caller must handle? Enforced by compiler No — optional
Propagation Must be declared in throws Propagates silently
on every method in the chain
Examples — JDK IOException, SQLException, NullPointerException,
ClassNotFoundException, IllegalArgumentException,
InterruptedException ClassCastException,
IndexOutOfBoundsException
Custom class extends Exception (not RuntimeException) RuntimeException
Recovery expectation Caller is expected to recover Caller should fix the code
or explicitly handle the case or handle at the boundary
WHAT CHECKED EXCEPTIONS SIGNAL:
"I cannot guarantee this operation succeeds — the file might be missing,
the network might be down. You (the caller) must decide what to do."
WHAT UNCHECKED EXCEPTIONS SIGNAL:
"Something is wrong with how this code is being used — fix the caller,
or catch this at the outermost boundary for error reporting."
Checked Exceptions — Compiler Enforcement in Action
When a method declares a checked exception in its throws clause, the compiler traces every call site and verifies that each one either handles or propagates the exception. This creates a visible contract across the API boundary.
1// File: CheckedExceptionDemo.java
2
3import java.io.FileNotFoundException;
4import java.io.IOException;
5import java.sql.SQLException;
6
7public class CheckedExceptionDemo {
8
9 // ---- Checked exception propagation chain ----
10
11 // Level 3 — throws checked exception: callers must handle or propagate
12 static String readConfigFile(String path) throws IOException {
13 if (path == null) throw new IOException("Path cannot be null");
14 if (path.endsWith(".missing")) throw new FileNotFoundException("Not found: " + path);
15 return "config-content-from-" + path;
16 }
17
18 // Level 2 — propagates the checked exception: must declare it too
19 static String loadAppConfig(String configPath) throws IOException {
20 // Compiler requires either catch(IOException) or throws IOException here
21 return readConfigFile(configPath); // propagates — no catch block
22 }
23
24 // Level 1 — handles it: catches IOException, does not propagate further
25 static void initializeApp(String configPath) {
26 try {
27 String content = loadAppConfig(configPath); // throws IOException
28 System.out.println("App initialized with: " + content);
29 } catch (FileNotFoundException fnfe) {
30 // FileNotFoundException is more specific — handle it first
31 System.out.println("Config missing — using defaults: " + fnfe.getMessage());
32 } catch (IOException ioe) {
33 // Catches remaining IOException subclasses
34 System.out.println("Config load failed — retrying: " + ioe.getMessage());
35 }
36 }
37
38 // ---- Multi-catch for unrelated checked exceptions ----
39 static void demonstrateMultiCatch(String input) {
40 try {
41 if (input.startsWith("db:")) {
42 throw new SQLException("DB error for: " + input);
43 }
44 throw new IOException("IO error for: " + input);
45 } catch (IOException | SQLException combinedException) {
46 // Both exceptions handled identically — multi-catch since Java 7
47 // The variable is implicitly final in multi-catch
48 System.out.println("Infra failure [" +
49 combinedException.getClass().getSimpleName() + "]: " +
50 combinedException.getMessage());
51 }
52 }
53
54 public static void main(String[] args) {
55 System.out.println("=== Checked exception propagation chain ===");
56 initializeApp("app.properties");
57 initializeApp("app.missing");
58 initializeApp(null);
59
60 System.out.println();
61
62 System.out.println("=== Multi-catch with unrelated checked exceptions ===");
63 demonstrateMultiCatch("file:/etc/config");
64 demonstrateMultiCatch("db:users");
65 }
66}Output:
=== Checked exception propagation chain ===
App initialized with: config-content-from-app.properties
Config missing — using defaults: Not found: app.missing
Config load failed — retrying: Path cannot be null
=== Multi-catch with unrelated checked exceptions ===
Infra failure [IOException]: IO error for: file:/etc/config
Infra failure [SQLException]: DB error for: db:users
Unchecked Exceptions — No Compiler Enforcement
Unchecked exceptions extend RuntimeException. The compiler does not require handling or declaration. They propagate silently through the call stack until they hit a catch block or terminate the thread.
1// File: UncheckedExceptionDemo.java
2
3import java.util.List;
4import java.util.Map;
5
6public class UncheckedExceptionDemo {
7
8 // NullPointerException — most common unchecked exception
9 // Not declared in method signature — propagates silently if not caught
10 static int getCartItemCount(Map<String, List<String>> userCarts, String userId) {
11 // NPE if userCarts is null (do not catch — fix the caller)
12 // Returns 0 if userId has no cart (intentional empty list)
13 return userCarts.getOrDefault(userId, List.of()).size();
14 }
15
16 // IllegalArgumentException — contract violation by the caller
17 static double applyDiscount(double price, double discountPercent) {
18 if (price < 0) {
19 throw new IllegalArgumentException(
20 "Price cannot be negative: " + price);
21 }
22 if (discountPercent < 0 || discountPercent > 100) {
23 throw new IllegalArgumentException(
24 "Discount must be 0-100, got: " + discountPercent);
25 }
26 return price * (1.0 - discountPercent / 100.0);
27 }
28
29 // IndexOutOfBoundsException — programming error: index beyond list size
30 static String getTopRecommendation(List<String> recommendations) {
31 if (recommendations == null || recommendations.isEmpty()) {
32 return "No recommendations available";
33 }
34 return recommendations.get(0); // safe — null/empty checked above
35 }
36
37 // Showing how unchecked propagates without declaration
38 static void methodA() {
39 methodB(); // no throws clause needed — IllegalStateException is unchecked
40 }
41
42 static void methodB() {
43 methodC(); // no throws clause needed
44 }
45
46 static void methodC() {
47 throw new IllegalStateException("System not ready"); // propagates up silently
48 }
49
50 public static void main(String[] args) {
51
52 System.out.println("=== IllegalArgumentException — contract violation ===");
53 System.out.printf("Rs.1299 after 20%% off: Rs.%.2f%n", applyDiscount(1299, 20));
54 try {
55 applyDiscount(500, 150); // invalid discount
56 } catch (IllegalArgumentException iae) {
57 System.out.println("Bad input at controller boundary: " + iae.getMessage());
58 }
59
60 System.out.println();
61
62 System.out.println("=== getCartItemCount — null-safe usage ===");
63 Map<String, List<String>> carts = Map.of(
64 "user-priya", List.of("item1","item2","item3"),
65 "user-rohan", List.of("item4")
66 );
67 System.out.println("Priya's cart: " + getCartItemCount(carts, "user-priya"));
68 System.out.println("Rohan's cart: " + getCartItemCount(carts, "user-rohan"));
69 System.out.println("Ananya's cart: " + getCartItemCount(carts, "user-ananya"));
70
71 System.out.println();
72
73 System.out.println("=== Silent propagation without throws declarations ===");
74 try {
75 methodA(); // IllegalStateException propagates A → B → C without any throws clause
76 } catch (IllegalStateException ise) {
77 System.out.println("Caught at boundary: " + ise.getMessage());
78 }
79 }
80}Output:
=== IllegalArgumentException — contract violation ===
Rs.1299 after 20% off: Rs.1039.20
Bad input at controller boundary: Discount must be 0-100, got: 150.0
=== getCartItemCount — null-safe usage ===
Priya's cart: 3
Rohan's cart: 1
Ananya's cart: 0
=== Silent propagation without throws declarations ===
Caught at boundary: System not ready
The throws Pollution Problem
This is the central argument in every "checked vs unchecked" debate in Java. When a checked exception propagates through a deep call stack, every method in the chain must declare it. If those intermediate methods cannot meaningfully handle the exception — they are just passing it through — the throws declaration adds noise without value.
1// File: ThrowsPollutionDemo.java
2
3import java.io.IOException;
4
5public class ThrowsPollutionDemo {
6
7 // ---- THE POLLUTION PROBLEM ----
8 // An IOException from a DAO layer propagates through service and controller layers.
9 // Neither the service nor the controller can do anything useful with it —
10 // they just re-declare it to satisfy the compiler.
11
12 // DAO layer — originates the checked exception
13 static String fetchFromDatabase(String query) throws IOException {
14 // IOException is checked — must be declared
15 if (query == null) throw new IOException("Null query");
16 return "result-for-" + query;
17 }
18
19 // Service layer — cannot handle IOException, just passes it through
20 // Now this method carries throws IOException even though it has nothing useful to say about it
21 static String getUserProfile(String userId) throws IOException {
22 return fetchFromDatabase("SELECT * FROM users WHERE id=" + userId);
23 }
24
25 // Controller layer — same problem, forced to declare or catch
26 static String handleProfileRequest(String userId) throws IOException {
27 return getUserProfile(userId);
28 }
29
30 // ---- THE SOLUTION: exception translation ----
31 // Wrap the checked exception in an unchecked domain exception at the DAO boundary.
32 // Upper layers deal with a meaningful domain exception without carrying IOException.
33
34 static class ProfileNotFoundException extends RuntimeException {
35 ProfileNotFoundException(String userId, Throwable cause) {
36 super("Profile not found for user: " + userId, cause);
37 }
38 }
39
40 static String fetchFromDatabaseClean(String query) throws IOException {
41 if (query == null) throw new IOException("Null query");
42 return "result-for-" + query;
43 }
44
45 // Translate at the DAO boundary — checked IOException → unchecked ProfileNotFoundException
46 static String getUserProfileClean(String userId) {
47 try {
48 return fetchFromDatabaseClean("SELECT * FROM users WHERE id=" + userId);
49 } catch (IOException ioException) {
50 // Translate: wrap and rethrow as unchecked — IOException is the cause (preserved)
51 throw new ProfileNotFoundException(userId, ioException);
52 }
53 }
54
55 // No throws declaration needed — cleaner signature
56 static String handleProfileRequestClean(String userId) {
57 return getUserProfileClean(userId); // clean — no checked exception propagation
58 }
59
60 public static void main(String[] args) {
61
62 System.out.println("=== With throws pollution: every layer must declare ===");
63 try {
64 String result = handleProfileRequest("user-001");
65 System.out.println("Profile: " + result);
66 } catch (IOException ioException) {
67 System.out.println("IOException at controller: " + ioException.getMessage());
68 }
69
70 System.out.println();
71
72 System.out.println("=== With exception translation: clean signatures ===");
73 try {
74 String result = handleProfileRequestClean("user-001");
75 System.out.println("Profile: " + result);
76 handleProfileRequestClean(null); // triggers translation
77 } catch (ProfileNotFoundException pnfe) {
78 System.out.println("Domain exception caught: " + pnfe.getMessage());
79 System.out.println("Root cause preserved: " +
80 pnfe.getCause().getClass().getSimpleName() +
81 " — " + pnfe.getCause().getMessage());
82 }
83 }
84}Output:
=== With throws pollution: every layer must declare ===
Profile: result-for-SELECT * FROM users WHERE id=user-001
=== With exception translation: clean signatures ===
Profile: result-for-SELECT * FROM users WHERE id=user-001
Domain exception caught: Profile not found for user: null
Root cause preserved: IOException — Null query
When to Use Each — The Decision Framework
This is the most practically important section for interviews and real design work. The decision between checked and unchecked is a design statement about who is responsible for handling the failure.
USE CHECKED EXCEPTION WHEN:
1. The failure is expected during normal operation
— A file the user specified might not exist
— A network call might fail due to a transient outage
— A database record might not be found (controversial — see below)
2. The caller can and should have a recovery strategy
— Show user a fallback option
— Retry with exponential backoff
— Return a default value
3. The method is part of a public library or framework API
— Callers you do not control must know about the failure mode
— It belongs to the API contract documentation
EXAMPLES: IOException, SQLException, ClassNotFoundException,
InterruptedException, your own FileParseException,
your own InsufficientFundsException (when overdraft handling is required)
USE UNCHECKED EXCEPTION WHEN:
1. The failure indicates a programming mistake
— Caller passed null where non-null was required
— Caller passed an invalid argument (negative price, empty ID)
— An object was used before initialization
2. The failure is so unlikely or unrecoverable that handling it everywhere is useless
— System configuration is invalid at startup
— A required service is unavailable (fail fast)
3. Intermediate layers have no useful response and would just propagate it
— Avoids throws pollution through service/controller layers
— Exception translation: wrap checked → unchecked at the DAO/adapter boundary
4. You are writing code where callers rarely if ever recover
— Most web framework exception handlers work this way
— Spring's DataAccessException family: all unchecked
EXAMPLES: NullPointerException, IllegalArgumentException, IllegalStateException,
your own OrderNotFoundException (if handled at controller only),
your own CatalogConfigurationException, your own PaymentValidationException
THE "NOT FOUND" EXCEPTION DEBATE:
Should OrderNotFoundException be checked or unchecked?
Checked: forces every caller to acknowledge the case — good for library code
where the caller might not know "not found" is possible
Unchecked: keeps service signatures clean — the controller handles it globally
Modern Java frameworks (Spring) use unchecked for most domain exceptions
Decision guide: if the calling code will almost always catch it at a single
boundary (a REST controller), make it unchecked. If different callers have
genuinely different recovery strategies, make it checked.
Real-World Example — PhonePe Wallet Transaction Service
A wallet service at PhonePe handles two fundamentally different failure categories. Insufficient balance and spending limit violations are checked — the caller (the payment controller) has real recovery strategies: show the user an alternative payment method, or prompt for a top-up. Database failures and configuration errors are unchecked — they are system problems that should propagate to a global error handler and trigger alerting.
1// File: WalletException.java
2
3// Checked: callers must decide how to handle wallet-level business failures
4public class WalletException extends Exception {
5
6 private final String walletId;
7 private final String errorCode;
8
9 public WalletException(String walletId, String errorCode, String message) {
10 super(message);
11 this.walletId = walletId;
12 this.errorCode = errorCode;
13 }
14
15 public String getWalletId() { return walletId; }
16 public String getErrorCode() { return errorCode; }
17}1// File: InsufficientBalanceException.java
2
3// Checked subclass: caller should show the user their actual balance
4public class InsufficientBalanceException extends WalletException {
5
6 private final double requested;
7 private final double available;
8
9 public InsufficientBalanceException(
10 String walletId, double requested, double available) {
11 super(walletId, "INSUFFICIENT_BALANCE",
12 String.format("Requested Rs.%.2f but wallet has Rs.%.2f", requested, available));
13 this.requested = requested;
14 this.available = available;
15 }
16
17 public double getRequested() { return requested; }
18 public double getAvailable() { return available; }
19}1// File: SpendingLimitException.java
2
3// Checked subclass: caller should redirect to limit management or suggest EMI
4public class SpendingLimitException extends WalletException {
5
6 private final double dailyLimit;
7 private final double alreadySpent;
8
9 public SpendingLimitException(
10 String walletId, double dailyLimit, double alreadySpent) {
11 super(walletId, "SPENDING_LIMIT_EXCEEDED",
12 String.format("Daily limit Rs.%.2f reached (spent Rs.%.2f today)",
13 dailyLimit, alreadySpent));
14 this.dailyLimit = dailyLimit;
15 this.alreadySpent = alreadySpent;
16 }
17
18 public double getDailyLimit() { return dailyLimit; }
19 public double getAlreadySpent() { return alreadySpent; }
20}1// File: WalletService.java
2
3public class WalletService {
4
5 // debit throws checked WalletException — callers have real recovery strategies
6 public double debit(String walletId, double amount, double balance,
7 double dailyLimit, double dailySpent)
8 throws InsufficientBalanceException, SpendingLimitException {
9
10 if (amount > balance) {
11 throw new InsufficientBalanceException(walletId, amount, balance);
12 }
13 if (dailySpent + amount > dailyLimit) {
14 throw new SpendingLimitException(walletId, dailyLimit, dailySpent);
15 }
16
17 double newBalance = balance - amount;
18 System.out.printf(" Debited Rs.%.2f from wallet %s. New balance: Rs.%.2f%n",
19 amount, walletId, newBalance);
20 return newBalance;
21 }
22
23 public static void main(String[] args) {
24
25 WalletService service = new WalletService();
26
27 System.out.println("=== Successful debit ===");
28 try {
29 service.debit("W-1001", 499.0, 2000.0, 5000.0, 1000.0);
30 } catch (InsufficientBalanceException ibe) {
31 System.out.println("Show top-up prompt: " + ibe.getMessage());
32 } catch (SpendingLimitException sle) {
33 System.out.println("Show limit settings: " + sle.getMessage());
34 }
35
36 System.out.println();
37
38 System.out.println("=== Insufficient balance — caller shows top-up ===");
39 try {
40 service.debit("W-1002", 1500.0, 800.0, 5000.0, 0.0);
41 } catch (InsufficientBalanceException ibe) {
42 // Specific recovery: show wallet balance and top-up option
43 System.out.printf(" [UI] Balance: Rs.%.2f | Needed: Rs.%.2f | Action: TOP UP%n",
44 ibe.getAvailable(), ibe.getRequested());
45 } catch (SpendingLimitException sle) {
46 System.out.println("Show limit settings: " + sle.getMessage());
47 }
48
49 System.out.println();
50
51 System.out.println("=== Spending limit exceeded — caller suggests EMI ===");
52 try {
53 service.debit("W-1003", 2000.0, 5000.0, 3000.0, 1800.0);
54 } catch (InsufficientBalanceException ibe) {
55 System.out.println("Show top-up prompt: " + ibe.getMessage());
56 } catch (SpendingLimitException sle) {
57 // Specific recovery: suggest converting to EMI or increasing limit
58 System.out.printf(" [UI] Limit: Rs.%.2f | Used today: Rs.%.2f | " +
59 "Action: CONVERT TO EMI%n",
60 sle.getDailyLimit(), sle.getAlreadySpent());
61 }
62
63 System.out.println();
64
65 System.out.println("=== Unchecked propagates to global handler (no throws) ===");
66 // Configuration error — unchecked RuntimeException, no throws declaration
67 try {
68 if (true) throw new IllegalStateException("Wallet service config not loaded");
69 } catch (IllegalStateException ise) {
70 // Only caught here for demo — in production this reaches the global handler
71 System.out.println(" [ALERT] System error: " + ise.getMessage());
72 }
73 }
74}Output:
=== Successful debit ===
Debited Rs.499.00 from wallet W-1001. New balance: Rs.1501.00
=== Insufficient balance — caller shows top-up ===
[UI] Balance: Rs.800.00 | Needed: Rs.1500.00 | Action: TOP UP
=== Spending limit exceeded — caller suggests EMI ===
[UI] Limit: Rs.3000.00 | Used today: Rs.1800.00 | Action: CONVERT TO EMI
=== Unchecked propagates to global handler (no throws) ===
[ALERT] System error: Wallet service config not loaded
Performance Considerations
Both checked and unchecked exceptions carry the same construction cost — the JVM captures the full stack trace when the exception object is created. There is no performance difference between them at runtime.
COMMON MISCONCEPTION:
"Unchecked exceptions are faster because the compiler doesn't check them."
FALSE — compiler enforcement is a compile-time activity.
At runtime, both exception types:
— Pay the same stack trace capture cost at construction
— Unwind the call stack the same way
— Produce identical diagnostic output
REAL PERFORMANCE CONCERN (applies to BOTH):
Creating exceptions in tight loops degrades performance.
A cache miss pattern that throws on every miss is not correct use of exceptions.
WRONG:
for (String id : thousandsOfIds) {
try {
catalog.findById(id); // throws NotFoundException if absent
} catch (NotFoundException ignored) {
defaults.add(id); // exception used as control flow — expensive
}
}
CORRECT:
for (String id : thousandsOfIds) {
Optional<Product> product = catalog.findByIdOptional(id);
product.ifPresentOrElse(
p -> results.add(p),
() -> defaults.add(id)); // no exception — just Optional
}
Best Practices
Use checked exceptions for recoverable failures in public APIs. If you are writing a library or a service layer that other teams call, checked exceptions document the failure modes every caller must plan for. InsufficientBalanceException, InsufficientStockException, QuotaExceededException — these are expected outcomes with specific recovery strategies. Hiding them as unchecked means callers silently ignore conditions they should handle explicitly.
Use unchecked exceptions for programming errors and system failures. NullPointerException, IllegalArgumentException, IllegalStateException signal that the calling code is wrong — not that the system encountered a normal edge case. These should propagate to a boundary handler (a @ControllerAdvice, a thread pool UncaughtExceptionHandler) for logging and generic error responses, not be caught individually at every call site.
Translate checked exceptions at architectural boundaries. A DAO that throws SQLException to a service layer that has no SQL context is leaking implementation details. Translate at the boundary: catch the checked SQLException inside the DAO, wrap it in an unchecked domain exception (InventoryServiceException), and preserve the original as the cause. The service layer deals with a domain concept; the root cause stays in the logs.
Never declare throws Exception or throws Throwable on public methods. These declarations are meaningless — callers cannot do anything useful with them except write their own catch (Exception e) block, which adds no information. Declare the specific checked exception types the method actually throws. If a method genuinely can throw many different exceptions and you have nothing more specific, the method is doing too much.
Common Mistakes
Mistake 1 — Swallowing Checked Exceptions With an Empty Catch Block
1// WRONG — catching the checked exception and doing nothing
2// The program continues as if the file loaded successfully
3public String loadConfig(String path) {
4 try {
5 return readFile(path);
6 } catch (IOException ioException) {
7 // Empty — exception swallowed
8 // The method returns null, callers get NullPointerException later
9 // The actual cause of the null is completely hidden
10 }
11 return null;
12}
13
14// CORRECT — handle meaningfully or rethrow as a more informative exception
15public String loadConfig(String path) {
16 try {
17 return readFile(path);
18 } catch (IOException ioException) {
19 // Option 1: return a safe default and log the issue
20 System.err.println("Config not loaded — using defaults: " + ioException.getMessage());
21 return "DEFAULT_CONFIG";
22 // Option 2: translate to an unchecked exception preserving the cause
23 // throw new ConfigurationException("Failed to load: " + path, ioException);
24 }
25}Mistake 2 — Making Every Domain Exception Checked
1// WRONG — three layers forced to carry throws declarations for something
2// that only the controller handles
3class OrderNotFoundException extends Exception { // checked
4 OrderNotFoundException(String id) { super("Order not found: " + id); }
5}
6
7// Now every method in the chain must declare it:
8Order getOrder(String id) throws OrderNotFoundException { ... }
9OrderSummary summarize(String id) throws OrderNotFoundException { ... }
10Invoice generateInvoice(String id) throws OrderNotFoundException { ... }
11// Clutters signatures; none of these intermediate methods do anything with it
12
13// CORRECT — make it unchecked if only the controller handles it
14class OrderNotFoundException extends RuntimeException { // unchecked
15 OrderNotFoundException(String id) { super("Order not found: " + id); }
16}
17// Now only the controller catches it — intermediate methods stay cleanMistake 3 — Wrapping Checked Exceptions Without Preserving the Cause
1// WRONG — original cause is lost; root cause disappears from logs
2public void processOrder(String orderId) {
3 try {
4 database.save(orderId);
5 } catch (java.sql.SQLException sqlException) {
6 throw new OrderProcessingException("Save failed");
7 // getCause() returns null — the SQL error vanishes
8 }
9}
10
11// CORRECT — always pass the original exception as the second argument
12public void processOrder(String orderId) {
13 try {
14 database.save(orderId);
15 } catch (java.sql.SQLException sqlException) {
16 throw new OrderProcessingException("Save failed for: " + orderId, sqlException);
17 // Full chain in logs: OrderProcessingException → SQLException → ORA-... error
18 }
19}Mistake 4 — Converting Checked to Unchecked by Catching and Ignoring the Type
1// WRONG — this converts a checked exception to an unchecked one,
2// but loses all information about what went wrong
3public void runTask() {
4 try {
5 riskyOperation(); // throws IOException
6 } catch (IOException ioException) {
7 throw new RuntimeException(ioException.getMessage());
8 // getCause() is null — the IOException is not preserved as the cause
9 }
10}
11
12// CORRECT — always pass the original as the cause argument
13public void runTask() {
14 try {
15 riskyOperation();
16 } catch (IOException ioException) {
17 throw new RuntimeException("Task failed: " + ioException.getMessage(), ioException);
18 // OR: throw new TaskException("Task failed", ioException); // domain exception preferred
19 }
20}Interview Questions
Q1. What is the difference between checked and unchecked exceptions in Java?
Checked exceptions extend Exception without going through RuntimeException. The Java compiler enforces handling: any method that calls code declaring throws IOException must either surround the call with try-catch or propagate it with its own throws IOException declaration. Unchecked exceptions extend RuntimeException. The compiler does not require handling or declaration — they propagate silently until caught. Checked exceptions represent expected failures the caller should plan for; unchecked exceptions typically represent programming errors or system failures that should propagate to a global error handler.
Q2. How does the compiler know whether an exception is checked or unchecked?
By inspecting the class hierarchy at compile time. If the exception class extends RuntimeException (directly or through any number of levels), the compiler classifies it as unchecked and does not require handling. If it extends Exception but not RuntimeException, it is checked and the compiler enforces handling or declaration. This is purely a class hierarchy check — there is no keyword, annotation, or other mechanism that marks an exception as checked or unchecked.
Q3. What is throws pollution and how do you solve it?
Throws pollution occurs when a checked exception declared in a low-level method propagates through multiple intermediate layers, forcing every method in the chain to declare throws even though those methods cannot meaningfully handle the exception. A SQLException originating in a DAO propagating through a service layer through a controller without any layer doing anything useful with it is throws pollution. The solution is exception translation: catch the checked exception at the architectural boundary (the DAO layer), wrap it in an unchecked domain exception while preserving the original as the cause, and throw the unchecked exception. Upper layers deal with domain concepts; the root cause is preserved in the exception chain for diagnostic purposes.
Q4. When should you make a custom exception checked versus unchecked?
Use checked when the failure is an expected business condition and different callers have genuinely different recovery strategies — InsufficientFundsException where one caller shows a top-up prompt and another caller sends a payment failure notification. Use unchecked when the failure is handled identically at a single boundary (a REST controller or message handler), when the failure indicates a programming or system error that cannot be recovered from, or when the exception would propagate through multiple layers that have no useful response. Modern Java frameworks like Spring made the pragmatic decision to use unchecked for most database and infrastructure exceptions — DataAccessException is unchecked — specifically to avoid throws pollution.
Q5. What happens when you catch a checked exception and rethrow it as an unchecked exception?
The checked enforcement disappears — callers of the rethrowing method no longer need to handle it. This is the exception translation pattern used at architectural boundaries. The critical requirement: always pass the original exception as the cause argument: throw new ServiceException("message", originalCheckedException). Without this, getCause() returns null and the root cause vanishes from logs. With it, printStackTrace() and logging frameworks show the full chain: ServiceException → IOException → connection refused, which is the diagnostic information that makes production debugging possible.
Q6. Can you convert an unchecked exception to a checked exception?
Yes — by catching the unchecked exception and rethrowing it as a checked exception, or by creating a checked exception class that wraps it. However, this is rarely the right direction. Converting unchecked to checked adds compiler enforcement where there was none, forcing all callers to handle something they were previously unaware of. This is sometimes appropriate when you are writing a library and want to document a failure mode that callers must acknowledge. In application code, the more common and pragmatic flow is checked → unchecked (exception translation at boundaries), not the reverse.
FAQs
Does catching an unchecked exception have any negative effect?
No — catching an unchecked exception works identically to catching a checked one. The only difference is the compiler does not require it. Catching IllegalArgumentException at a REST controller to return a 400 response is correct and common. The concern is catching unchecked exceptions inside business logic as if they represent normal control flow, which hides bugs that should be fixed.
Why does Java have checked exceptions when other languages do not?
Java introduced checked exceptions to make failure modes visible in API contracts. The argument: a method that can throw IOException must declare it, so API documentation does not need to mention it explicitly — the signature itself is the documentation. The counterargument: large-scale experience showed that checked exceptions cause throws pollution and often result in empty catch blocks (swallowing exceptions) because developers found handling them everywhere too burdensome. Modern languages like Kotlin, Scala, and C# dropped checked exceptions entirely. Java never removed them for backward compatibility, but modern Java libraries and frameworks increasingly use unchecked.
What is the difference between throws and throw in the context of checked exceptions?
throw is the statement that creates and dispatches an exception: throw new IOException("message"). throws is the declaration in a method signature that announces checked exceptions a method may propagate: public void readFile() throws IOException. For unchecked exceptions, throw works identically but throws in the signature is optional — you can declare it for documentation purposes, but the compiler does not require it.
If a method throws both a checked and an unchecked exception, how do you declare the signature?
Declare only the checked exceptions: public void save() throws IOException. Unchecked exceptions do not need to appear in the throws clause, though you can add them for documentation clarity — throws IOException, IllegalArgumentException is valid but the IllegalArgumentException part is not enforced by the compiler. IDEs and tools like Javadoc may surface the unchecked ones from the method body regardless.
Can you override a method and add a new checked exception?
No. When overriding a method, the overriding method's throws clause can only declare checked exceptions that are the same as or narrower than those declared in the parent method. You cannot add a new checked exception that the parent did not declare, because callers of the parent type would not know to handle it. You can remove checked exceptions (declare fewer), and you can always declare unchecked exceptions since callers do not need to handle them. This constraint enforces the Liskov Substitution Principle: a subtype must be usable wherever its parent type is expected.
Is InterruptedException checked or unchecked, and how should you handle it?
InterruptedException is a checked exception — it extends Exception directly, not RuntimeException. It is thrown when a thread waiting, sleeping, or blocked is interrupted by another thread calling thread.interrupt(). The correct handling is to either propagate it (throws InterruptedException in the method signature) or, if you must catch it, restore the interrupt flag: catch (InterruptedException e) { Thread.currentThread().interrupt(); }. Never swallow it silently — doing so clears the interrupt flag and the thread no longer responds to future interruption requests, which breaks cancellation and shutdown logic.
Summary
The checked versus unchecked distinction is a compile-time enforcement mechanism that communicates design intent through the class hierarchy. Checked exceptions say "this is an expected, recoverable failure — every caller must decide how to respond." Unchecked exceptions say "this is a programming mistake or system failure — handle it at the boundary."
Two decisions drive most of the design work: whether a custom exception should extend Exception (checked) or RuntimeException (unchecked), and where to apply exception translation — catching checked exceptions at architectural boundaries and rethrowing them as meaningful unchecked domain exceptions while preserving the original cause.
The throws pollution problem explains why modern Java frameworks went unchecked for infrastructure exceptions. The cause-chain preservation rule explains why production debugging is possible at all. Both together are what interviewers at product companies test when they ask "when would you use checked versus unchecked" — they are listening for whether you can explain the design tradeoff, not just name examples.
What to Read Next
Learn the most common exceptions you'll run into in Java.