Java Unchecked Exceptions
What Makes an Exception "Unchecked"
An unchecked exception is one the compiler does not force you to catch or declare. In Java this means:
RuntimeExceptionand all its subclasses, andErrorand all its subclasses.
Throwable
├── Error → unchecked
│ ├── OutOfMemoryError
│ └── StackOverflowError
└── Exception
└── RuntimeException → unchecked
├── ArithmeticException
├── NullPointerException
├── ClassCastException
├── IllegalArgumentException
│ └── NumberFormatException
├── IllegalStateException
├── IndexOutOfBoundsException
├── UnsupportedOperationException
└── ConcurrentModificationException
Like checked exceptions, unchecked exceptions are thrown, propagate and can be caught in exactly the same way. The only difference is at compile time: methods can throw them without a throws clause, and callers can call those methods without try...catch.
Why the Compiler Doesn't Force Handling
Unchecked exceptions mostly represent programming errors: dividing by a value that should never be zero, using a null reference, reading past the end of an array, calling a method with an argument that breaks its contract. Almost any line of Java could cause one:
- Every method call on an object can throw
NullPointerException. - Every array access can throw
ArrayIndexOutOfBoundsException. - Every integer division can throw
ArithmeticException.
If these were checked, nearly every method would need try...catch blocks or long throws clauses, and the important declarations (like IOException) would be lost in noise. More importantly, catching them rarely helps: the correct response to a NullPointerException caused by a bug is to fix the bug, not to write recovery code for it.
So the Java designers split exceptions by who can do something about them:
| Checked | Unchecked | |
|---|---|---|
| Base class | Exception (not RuntimeException) | RuntimeException, Error |
| Compiler enforcement | Must be caught or declared | Not enforced |
| Typically represents | Expected external failures (I/O, network, database) | Bugs and contract violations |
| Who can fix it | Code handles it at runtime (retry, fallback, message) | A developer fixes the code |
| Examples | IOException, SQLException, InterruptedException | NullPointerException, IllegalArgumentException, ArithmeticException |
| In the method signature | Listed in throws | Usually documented with Javadoc @throws only |
Propagation Without Declarations
public class NoDecl {
static int unitPrice(int total, int qty) {
return total / qty; // may throw ArithmeticException
}
static int parseQty(String text) {
return Integer.parseInt(text); // may throw NumberFormatException
}
public static void main(String[] args) {
System.out.println(unitPrice(1200, parseQty("4")));
System.out.println(unitPrice(1200, parseQty("0")));
}
}
Output:
300
Exception in thread "main" java.lang.ArithmeticException: / by zero
at NoDecl.unitPrice(NoDecl.java:3)
at NoDecl.main(NoDecl.java:12)
Neither method has a throws clause and main has no try, yet the program compiles. When qty is 0, the exception propagates from unitPrice to main and ends the program, exactly like a checked exception would. "Unchecked" doesn't mean "harmless" — it means the compiler won't remind you.
This is also the main risk: nothing in unitPrice's signature warns callers that it can fail. Document such behaviour with Javadoc, and validate inputs so the failure is clear:
/**
* @throws IllegalArgumentException if qty is not positive
*/
static int unitPrice(int total, int qty) {
if (qty <= 0) {
throw new IllegalArgumentException("qty must be positive, got " + qty);
}
return total / qty;
}
IllegalArgumentException with a specific message tells the caller exactly what they did wrong, while / by zero only describes the symptom.
Unchecked Exceptions That Protect You
Several JDK methods throw unchecked exceptions on purpose, to turn silent bugs into visible failures. This approach is called fail-fast: stop immediately at the point where something is wrong, instead of continuing with corrupt data.
Integer overflow
public class Overflow {
public static void main(String[] args) {
int stock = Integer.MAX_VALUE;
System.out.println("plain +: " + (stock + 1));
try {
System.out.println("addExact: " + Math.addExact(stock, 1));
} catch (ArithmeticException e) {
System.out.println("addExact: ArithmeticException: " + e.getMessage());
}
try {
System.out.println(Math.toIntExact(3_000_000_000L));
} catch (ArithmeticException e) {
System.out.println("toIntExact: " + e.getMessage());
}
}
}
Output:
plain +: -2147483648
addExact: ArithmeticException: integer overflow
toIntExact: integer overflow
Plain + silently wraps around: adding 1 to the largest int produces the most negative int. In an inventory or banking system that would be a disaster that nobody notices until the reports are wrong. Math.addExact, subtractExact, multiplyExact and toIntExact perform the same operations but throw ArithmeticException when the result doesn't fit. Use them wherever an overflow would be a bug, such as money stored in long paise or cents.
Modifying a collection while iterating
import java.util.*;
public class Cme {
public static void main(String[] args) {
List<String> cart = new ArrayList<>(List.of("pen", "expired-coupon", "bag", "ink"));
try {
for (String item : cart) {
if (item.startsWith("expired")) {
cart.remove(item);
}
}
} catch (ConcurrentModificationException e) {
System.out.println("ConcurrentModificationException, cart = " + cart);
}
List<String> cart2 = new ArrayList<>(List.of("pen", "expired-coupon", "bag", "ink"));
cart2.removeIf(item -> item.startsWith("expired"));
System.out.println("removeIf: " + cart2);
}
}
Output:
ConcurrentModificationException, cart = [pen, bag, ink]
removeIf: [pen, bag, ink]
The enhanced for loop uses an iterator. When cart.remove(item) changes the list behind the iterator's back, the iterator detects the change on its next step and throws ConcurrentModificationException rather than risk skipping or repeating elements. The fix is not to catch the exception but to use the right tool: removeIf, or an explicit Iterator with iterator.remove().
The detection is "best effort": the JDK documentation explicitly says it isn't guaranteed in every case (for example, removing the second-to-last element can end the loop without an exception). Never rely on catching it — it exists to expose a bug.
Errors: The Other Unchecked Family
Error subclasses are also unchecked, but they signal problems with the JVM or the environment rather than with your application logic.
public class Deep {
static int depth = 0;
static long factorial(long n) {
depth++;
return n * factorial(n - 1); // bug: no base case
}
public static void main(String[] args) {
try {
factorial(5);
} catch (StackOverflowError e) {
System.out.println("StackOverflowError after more than " + (depth / 1000) * 1000 + " calls");
}
}
}
Output (the exact number depends on the JVM's thread stack size and settings):
StackOverflowError after more than 7000 calls
Each method call uses some memory on the thread's stack. Without a base case (if (n <= 1) return 1;), the recursion never stops, the stack runs out and the JVM throws StackOverflowError. The example catches it only to show the depth; in real code, catching Errors is almost never correct, because the JVM may be in an unreliable state (especially after OutOfMemoryError). The fix is in the code — here, the missing base case.
Practical Example: Unchecked Exceptions in a Service Layer
Modern Java applications (Spring Boot, Jakarta EE, Quarkus) use unchecked exceptions heavily. Business code throws them wherever a rule is broken, without adding throws clauses everywhere, and one central handler at the boundary translates them into responses. This example imitates that structure with a tiny product reservation service.
import java.util.*;
public class Api {
static class NotFoundException extends RuntimeException {
NotFoundException(String message) { super(message); }
}
record Product(String sku, String name, int stock) { }
static final Map<String, Product> catalog = new HashMap<>(Map.of(
"PEN-01", new Product("PEN-01", "Gel pen", 40),
"BAG-07", new Product("BAG-07", "Laptop bag", 0)));
// Service layer: throws unchecked exceptions, no throws clauses needed
static Product find(String sku) {
Product p = catalog.get(sku);
if (p == null) throw new NotFoundException("no product " + sku);
return p;
}
static String reserve(String sku, String qtyText) {
int qty = Integer.parseInt(qtyText);
if (qty <= 0) throw new IllegalArgumentException("quantity must be positive: " + qty);
Product p = find(sku);
if (p.stock() < qty) throw new IllegalStateException(p.name() + " has only " + p.stock() + " in stock");
catalog.put(sku, new Product(sku, p.name(), p.stock() - qty));
return "reserved " + qty + " x " + p.name();
}
// Boundary: one place translates exceptions into HTTP-style responses
static String handle(String sku, String qty) {
try {
return "200 " + reserve(sku, qty);
} catch (RuntimeException e) {
int status = switch (e) {
case NotFoundException nf -> 404;
case IllegalArgumentException iae -> 400; // also covers NumberFormatException
case IllegalStateException ise -> 409;
default -> 500;
};
return status + " " + e.getMessage();
}
}
public static void main(String[] args) {
System.out.println(handle("PEN-01", "5"));
System.out.println(handle("PEN-01", "two"));
System.out.println(handle("PEN-01", "-1"));
System.out.println(handle("INK-03", "1"));
System.out.println(handle("BAG-07", "1"));
System.out.println("PEN-01 stock now " + catalog.get("PEN-01").stock());
}
}
Output:
200 reserved 5 x Gel pen
400 For input string: "two"
400 quantity must be positive: -1
404 no product INK-03
409 Laptop bag has only 0 in stock
PEN-01 stock now 35
How it works:
NotFoundException extends RuntimeException— a custom unchecked exception (the next lesson covers custom exceptions in detail). Because it's unchecked,findandreserveneed nothrowsclause.- Each rule throws the exception that describes it. Non-numeric text makes
Integer.parseIntthrowNumberFormatException; a non-positive quantity is an invalid argument; a missing product is "not found"; insufficient stock is a state conflict. - The state changes only after every check passes. Only the first request modifies the catalogue, which is why the pen stock ends at
40 - 5 = 35. handleis the boundary. It catchesRuntimeExceptiononce and maps each type to a status code using aswitchwith type patterns (Java 21). Eachcase Type name ->matches wheneis an instance of that type. The order matters as it does forcatchblocks: a case forIllegalArgumentExceptionplaced above a case for one of its subclasses would make the subclass case unreachable, and the compiler rejects that.default -> 500covers everything unexpected, such as aNullPointerExceptionfrom a real bug. Those become "500 Internal Server Error" and should be logged with their stack trace and fixed.
In Spring Boot, the same idea is implemented with a class annotated @RestControllerAdvice containing @ExceptionHandler methods; the business code stays free of HTTP concerns and of try...catch blocks.
Choosing Between Checked and Unchecked for Your Own Code
When you throw an exception from your own method, use these questions:
| Question | If yes |
|---|---|
| Does the problem come from the caller breaking the method's contract (bad argument, wrong order of calls)? | Unchecked (IllegalArgumentException, IllegalStateException) |
| Could the caller have prevented it with a simple check beforehand? | Unchecked |
| Is it a foreseeable condition outside anyone's control that the immediate caller should handle (file missing, connection lost)? | Checked |
| Will the exception be thrown from inside lambdas or streams? | Unchecked (or wrap the checked one) |
| Is the code a framework-style application where one central handler deals with failures? | Unchecked is the common convention |
Oracle's original guideline summarises it: "If a client can reasonably be expected to recover from an exception, make it a checked exception. If a client cannot do anything to recover from the exception, make it an unchecked exception." In practice, many modern codebases lean toward unchecked exceptions for business errors and reserve checked exceptions for APIs where the caller genuinely must make a recovery decision.
Catching Unchecked Exceptions: When It Is Appropriate
Not having to catch unchecked exceptions doesn't mean you never should. Catching them is appropriate when:
- Parsing untrusted input.
NumberFormatException,DateTimeParseExceptionandIllegalArgumentExceptionfromUUID.fromStringorEnum.valueOfare the normal way to detect invalid user input. - At application boundaries. Request handlers, message consumers, scheduled jobs and thread-pool tasks catch
RuntimeExceptionso one failure doesn't take down the whole process, and log it with the full stack trace. - When a library uses unchecked exceptions for expected failures. For example, JPA's
NoResultExceptionor Spring'sDataIntegrityViolationExceptionfor a duplicate key.
Avoid catching NullPointerException, ArrayIndexOutOfBoundsException or ClassCastException as a way to control normal program flow. Prevent them with checks (!= null, bounds checks, instanceof patterns) instead.