Why does the compiler force a catch for IOException but not for NullPointerException? Both are exceptions. Both can crash a program. Yet one demands a try / catch (or a throws clause) before the code will compile, and the other passes the compiler and only fails at runtime. This lesson explains the split, why Java draws the line where it does, and how to decide which side custom exceptions belong on.
Every exception in Java is an object whose class sits somewhere in a hierarchy rooted at java.lang.Throwable. That root has two direct children: Error and Exception. Inside Exception, one subclass is treated specially by the compiler: RuntimeException. The split that drives this whole lesson is the boundary between "things that descend from RuntimeException (or Error)" and "everything else under Exception."
The cyan nodes are checked exceptions. The orange nodes are unchecked (everything under RuntimeException). The red Error family is also unchecked, but treated differently again because errors signal JVM-level problems that application code is not expected to recover from. The rule is purely structural: the compiler looks at the class hierarchy of the thrown type, not at the name, not at the message, not at anything configurable.
A compact form of the rule:
RuntimeException or any of its subclasses? Unchecked.Error or any of its subclasses? Unchecked.Exception but not under RuntimeException? Checked.That single test decides whether the compiler enforces handling.
A checked exception is one the compiler tracks. If a method can throw a checked exception, every caller has to do one of two things, and the compiler will refuse to build the code if neither is done:
try / catch block that catches that exception type (or one of its supertypes).throws clause on the method signature, pushing the obligation onto the caller of that method.This is the handle-or-declare rule from the throws Keyword lesson. It applies only to checked exceptions. Skip it for a checked exception and the code does not compile.
Here is a small e-commerce snippet that reads an inventory file. Files.readAllLines declares throws IOException, which is a checked exception.
This does not compile. The compiler emits:
That message is the compiler enforcing the handle-or-declare rule. To make it compile, declare the exception:
Or handle it:
Output (assuming `inventory.txt` is missing):
Both compile. The first version says "this method also fails on missing files; the next caller up the chain deals with it." The second version says "handled right here." Either is fine. What's not fine is ignoring the exception, and the compiler will not allow that.
An unchecked exception is one the compiler does not track at the contract level. A method that throws NullPointerException does not have to declare it. Callers don't have to catch it. Catching it or declaring it in a throws clause is allowed, but neither is required.
A parsing snippet:
Double.parseDouble throws NumberFormatException when the string is not a valid number. NumberFormatException is a subclass of IllegalArgumentException, which is a subclass of RuntimeException. The chain ends at RuntimeException, so it's unchecked. The code above compiles without any try / catch and without any throws clause. If rawPrice happens to be "banana", the exception flies at runtime:
The build succeeded. The runtime crashed. That is the bargain unchecked exceptions strike. The compiler stays out of the way, and the responsibility for thinking about failure modes shifts to the author.
Unchecked exceptions have a runtime cost. Constructing a stack trace is expensive, and using exceptions for ordinary control flow (parsing strings inside a tight loop, for example) can dominate hot-path cost. Prefer methods that return a status (Optional, a boolean) when failures are routine.
The two families differ on more than just compiler enforcement. Here's the full picture:
| Aspect | Checked | Unchecked |
|---|---|---|
| Root class | Exception (excluding RuntimeException subtree) | RuntimeException or Error |
| Compiler enforces handle-or-declare? | Yes | No |
Must appear in throws clause? | Yes, if the method can throw it | No, but allowed |
| Typical cause | External, recoverable failure | Programming bug or invalid state |
| Caller can reasonably retry? | Usually yes | Usually no |
| Examples | IOException, SQLException, InterruptedException | NullPointerException, IllegalArgumentException, ArithmeticException |
| Found mostly in | I/O, networking, JDBC, concurrency | Validation, internal logic, type conversions |
The table captures the difference in mechanics. The more interesting question is why Java draws the line there. Why did the language designers decide that file I/O failures should be checked, and null dereferences should not? That's the topic of the next two sections.
These are the checked exceptions most common in real code. All of them sit directly under Exception, not under RuntimeException.
`IOException` is the parent of a large family in java.io and java.nio. Reading a file that does not exist throws FileNotFoundException (a subclass). A network read that times out throws SocketTimeoutException (also a subclass). Writing to a full disk throws IOException itself. Every one of these is a failure the caller could plausibly handle: prompt for a different path, retry the network call, alert the operator about disk usage.
`SQLException` lives in java.sql. Every JDBC operation that talks to a database can fail in dozens of ways (connection refused, syntax error, constraint violation, deadlock), and SQLException is the umbrella type for all of them. The caller decides whether to retry, surface the error to the user, or roll back a transaction.
`InterruptedException` is the signal one thread sends another to request a stop. Blocking calls like Thread.sleep, Object.wait, and most BlockingQueue operations throw it when the thread they're running on gets interrupted. Treating it as checked forces every concurrent piece of code to think explicitly about cancellation.
`ClassNotFoundException` is thrown by Class.forName and the reflection APIs when the requested class can't be loaded. It's checked because the failure is environmental: the class exists in source code, but the runtime classpath might not include it.
A small program that exercises one of these:
Output (assuming `stock.txt` is missing):
The program recovers from the missing file by carrying on with empty inventory. That kind of recovery is the reason these exceptions are checked: the caller has a real choice to make, and the compiler nudges that choice into existence.
These show up just as often, but the compiler stays quiet about them. Each one usually points at a programming mistake rather than an environmental failure.
| Exception | Typical cause |
|---|---|
NullPointerException | Dereferencing a null reference |
IllegalArgumentException | Passing an argument that violates the method's contract |
IllegalStateException | Calling a method when the object is not in a valid state for it |
ArrayIndexOutOfBoundsException | Indexing past the end of an array |
ClassCastException | Casting an object to a type it isn't |
ArithmeticException | Integer divide by zero, modulo by zero |
NumberFormatException | Parsing a non-numeric string as a number |
A few short examples in an e-commerce context:
The first example is a bug: customerName was never set before being used. The second example is also a bug, but on the caller's side: the quantity passed violates the method's contract. In both cases, the right response is not "retry with a different value" but "find and fix the code that produced this." That's the family of failure unchecked exceptions are designed for.
The reasoning makes the language designers' choice clearer. Forcing every method that dereferences a reference to declare throws NullPointerException would push noise into nearly every method signature in the JDK. There's almost no useful recovery path from a null dereference at the call site, only at a process boundary or top-level handler. Making the exception unchecked keeps the type system honest without burying useful contracts under boilerplate.
Error FamilyError is the third branch of Throwable, alongside Exception and (via Exception) RuntimeException. Errors are unchecked, but they sit apart in the design because they signal failures at a level below normal application code.
| Error | What it signals |
|---|---|
OutOfMemoryError | The JVM cannot allocate more memory on the heap (or in some pools) |
StackOverflowError | A thread's call stack has grown past its limit (usually runaway recursion) |
NoClassDefFoundError | A class that was available at compile time is missing at runtime |
AssertionError | A failed assert statement (or one thrown directly by a test) |
LinkageError | Class file incompatibilities, often after a partial deployment |
The rule of thumb is: don't catch errors in normal code. By the time OutOfMemoryError fires, the JVM is already in trouble, and the catch block might not be able to allocate the simple objects it needs to log the failure. By the time StackOverflowError fires, the stack is too deep to do much useful recovery. By the time NoClassDefFoundError fires, the deployment is broken in a way no try / catch can patch over.
A frame or framework that owns a process boundary (a servlet container, a test runner) might catch Throwable so that one broken request doesn't take down the whole process. That's the exception, and it's deliberate. Application code generally treats errors as fatal and lets them propagate.
The catch here is a teaching device to show that errors are catchable. In production code, the right fix for a StackOverflowError is to find the missing base case in the recursion, not to wrap the recursion in a try block.
When designing a custom exception type, the choice of base class determines which family the exception belongs to. Extending Exception makes it checked. Extending RuntimeException makes it unchecked. That single decision shapes how callers will use it.
The guideline that's held up best over time goes like this:
try / catch cannot do.Two e-commerce examples make the choice concrete.
First, OrderNotFoundException. The repository looks up an order by id, and the order is not in the database. Is that a recoverable failure the caller must handle, or is it a bug?
The honest answer is: it depends on the context. If the call is findOrder("ORD-1001") where ORD-1001 came from a URL the user typed (or might have typed wrong), then a missing order is an expected outcome and the caller has a real choice (show a 404 page, prompt for a different id). Make it a checked exception, or better, return Optional<Order>. If the call is findOrder(latestOrder.getId()) where latestOrder was just created two lines up, then a missing order signals an internal invariant violation. Make it unchecked.
Some teams choose unchecked here because the same exception type often serves both contexts, and they prefer not to force the "lookup-by-untrusted-id" path to wrap every call in a try. They handle the user-facing case once at the controller layer with a generic handler.
Second, InventoryShortageException. A customer tries to reserve five units of a product that has only two in stock. Is the caller required to handle this?
Stock shortages are expected outcomes of a normal sale. Every sale path has to decide what to do when the requested quantity is unavailable (suggest a smaller quantity, offer a back-order, fail the checkout cleanly). Making the exception checked forces every reservation call to consider that path explicitly. That's a real fit for the "checked" intent.
A strong case can be made for either choice in either example. What matters is being deliberate. A team that picks checked for every domain failure and a team that picks unchecked for every domain failure can both be coherent. A codebase that flips between the two with no reasoning ends up confusing.
The decision diagram is short by design. Two questions, one answer. The wrong choice affects every caller of the method.
A real shift has happened in the Java community over the last decade or so. Many teams now lean toward unchecked exceptions even in places where the classic advice would have used checked.
Three forces pushed that shift.
Lambdas and streams. The Stream API and the functional interfaces in java.util.function (like Function, Predicate, Consumer) do not declare checked exceptions. A lambda body that calls a method which throws a checked exception will not compile inside a stream pipeline. Either wrap the checked exception in a RuntimeException, or use unchecked exceptions in the first place. The pragmatic outcome is more unchecked exceptions in code that uses streams heavily.
The wrapping pattern is everywhere in modern Java code, and it betrays the awkwardness. Every line of plumbing around the actual logic is a small tax. Code that uses unchecked exceptions throughout avoids the tax.
`Callable` and `CompletableFuture`. Concurrent APIs hide the original exception type behind a wrapper (ExecutionException for Future.get, CompletionException for CompletableFuture). Declaring throws IOException on a method submitted to an executor doesn't help the caller, because they'll receive the wrapped form anyway. That weakens the case for checked exceptions in any code that crosses an async boundary.
Framework conventions. Spring, Hibernate, and several large frameworks deliberately convert checked exceptions to unchecked at their boundaries. The argument is that most callers don't have a useful recovery path at the call site, so forcing them to handle or declare is bureaucratic without being useful. Hibernate, for example, wraps every SQLException in a HibernateException, which is unchecked.
None of this means checked exceptions are wrong. The JDK still uses them, and well-designed APIs in java.nio.file, java.sql, and the concurrency utilities still throw them. What's changed is the default. Twenty years ago the default advice was "use checked for everything callers should know about." Today the default is "use unchecked unless there's a specific reason to use checked." Both are defensible.
A common pattern. The compiler complains about an unhandled checked exception. The shortest possible fix is to catch the exception and ignore it.
What's wrong with this code?
The catch block silences the compiler, but it makes the failure worse, not better. If the file is missing, load returns null. The caller in main then calls lines.size() on a null reference, which throws NullPointerException with no information about why. A real failure (file missing, permission denied) becomes a different, less informative failure (null dereference) several frames away.
There are two clean fixes. Which one is right depends on the design.
Fix 1: let the exception propagate. If the method can't load inventory, the caller should know. Declare the exception:
Fix 2: convert to a domain-meaningful unchecked exception when the caller really has no recovery path. This is the same wrapping pattern used in framework code:
UncheckedIOException is a real class in java.io, added in Java 8 specifically for this pattern. The original IOException is preserved as the cause, so the failure isn't lost; it's just no longer in the compile-time contract.
Both fixes preserve information. The original "ignore" pattern destroys it. The number of bugs hidden behind empty catch blocks across all Java code ever written is large.
To pull the rule together, here are the four exception types the lesson opened with, classified and explained.
Parsing a price string with `Double.parseDouble("29.99")` throws NumberFormatException on bad input. NumberFormatException extends IllegalArgumentException, which extends RuntimeException. Unchecked. The compiler doesn't require handling. The failure usually means the input is malformed, and the right response is either to validate the input first or to catch the exception explicitly when the input is untrusted.
Reading an inventory file with `Files.readAllLines` throws IOException on disk errors. IOException extends Exception directly, not RuntimeException. Checked. The compiler enforces handle-or-declare. The caller has real choices: retry, fall back to a default, surface the error to an operator.
Looking up an order by missing id is a custom exception, OrderNotFoundException. As discussed, the choice depends on context. If the same lookup path is used for both trusted internal callers (where missing means bug) and untrusted external callers (where missing means user error), some teams pick unchecked and handle the user-facing case at a single boundary. If the lookup is used only by code that has to deal with the missing case explicitly, checked is also a reasonable answer.
Reserving stock when there isn't enough is a custom exception, InventoryShortageException. The failure is part of the domain: a sale that can't be completed because of stock is a normal outcome, not a bug. Checked is a strong fit, because every reservation path has to think about this case. Some teams still go unchecked here, with the reasoning that "every checkout has a single high-level handler anyway." Both work. Pick one and stay consistent.
The summary table:
| Failure | Exception | Family | Why |
|---|---|---|---|
| Bad numeric string | NumberFormatException | Unchecked | Caller's bug (or upstream input layer's bug); programmatic check is cheap |
| File missing or unreadable | IOException | Checked | External, recoverable; caller has real choices |
| Order id not found | OrderNotFoundException (custom) | Either | Depends on whether missing means "user error" or "internal bug" |
| Stock shortage on reservation | InventoryShortageException (custom) | Usually checked | Part of normal domain flow, every caller must handle |
The rows for custom exceptions are the ones that require the most thought. The mechanics are easy (extend Exception or extend RuntimeException). The judgement is harder.
10 quizzes