Java splits exceptions into checked ones the compiler makes you handle and unchecked ones it doesn’t. Learn try, catch and finally, how to throw and wrap exceptions well, and how try-with-resources closes things in reverse order.
An exception is how a Java method says “I can’t finish this”. Some exceptions the compiler makes you deal with, and some it doesn’t, and that split shapes how Java code handles failure. The other half of the story is cleanup: closing what you opened, even when something goes wrong halfway.
This post covers the exception hierarchy, checked and unchecked exceptions, try, catch and finally, throwing and wrapping, custom exceptions, and try-with-resources. It ends with three habits to avoid. Every program below was run on Java 25, and its output is pasted from the run. To run one yourself, save it as Main.java and run java Main.java.
The exception hierarchy
Every exception in Java is an object whose class extends Throwable. Walking up the parent classes of three common ones shows the whole family tree:
void main() {
var types = List.of(
IOException.class,
IllegalArgumentException.class,
StackOverflowError.class);
for (var type : types) {
var chain = new ArrayList<String>();
for (Class<?> c = type; c != null; c = c.getSuperclass()) {
chain.add(c.getSimpleName());
}
IO.println(String.join(" -> ", chain));
}
}
It prints:
IOException -> Exception -> Throwable -> Object
IllegalArgumentException -> RuntimeException -> Exception -> Throwable -> Object
StackOverflowError -> VirtualMachineError -> Error -> Throwable -> Object
Under Throwable there are two branches:
Errormeans the JVM itself is in trouble: out of memory, a stack overflow, a broken class file. Your code usually can’t fix these, so you don’t catch them.Exceptionmeans something went wrong that a program might handle.
Exception splits once more. RuntimeException and its subclasses are unchecked. Every other Exception is checked. Error is unchecked too. The difference is entirely about what the compiler makes you do.
Checked exceptions: catch it or declare it
A checked exception must be either caught or declared in the method’s throws clause, and the compiler refuses code that does neither. new URI(text) can throw the checked URISyntaxException, so this doesn’t build:
URI parse(String text) {
return new URI(text);
}
void main() {
IO.println(parse("https://example.com/docs"));
}
The build fails with:
Main.java:2: error: unreported exception URISyntaxException; must be caught or declared to be thrown
return new URI(text);
^
The message names both ways out. You can catch the exception and deal with it where it happens, or you can add throws URISyntaxException and make it your caller’s problem:
String hostOf(String text) {
try {
return new URI(text).getHost();
} catch (URISyntaxException e) {
IO.println("bad address: " + e.getMessage());
return "unknown";
}
}
URI parse(String text) throws URISyntaxException {
return new URI(text);
}
void main() throws URISyntaxException {
IO.println(hostOf("https://example.com/docs"));
IO.println(hostOf("https://example.com/my docs"));
IO.println(parse("https://example.org/api").getPath());
}
It prints:
example.com
bad address: Illegal character in path at index 22: https://example.com/my docs
unknown
/api
hostOf catches the exception and returns a fallback, so its callers never see it. parse declares it, so every caller has to make the same choice again. main declares it too. If an exception escaped main, the program would stop with a stack trace, as the first part of this series showed.
An unchecked exception needs neither. Integer.parseInt throws NumberFormatException, a RuntimeException, and you can call it with no try and no throws at all.
Explain it like I’m ten
A checked exception is a “fragile” sticker on a parcel. Everyone who handles the parcel has to notice the sticker. You either open the parcel carefully yourself, or you hand it on with the sticker still showing. You’re not allowed to peel the sticker off and pretend it isn’t there.
An unchecked exception is a parcel with no sticker. It can still break, but nobody is forced to think about it on the way.
The precise version
The compiler tracks which checked exceptions each statement can throw. It reads that from the throws clauses of the methods and constructors it calls, and from your own throw statements. Each one must be handled by an enclosing catch for that type or a parent type, or listed in the throws clause of the method you’re writing. The check runs at compile time, and none of it exists in the running program: the JVM treats checked and unchecked exceptions the same way.
Where the analogy breaks: a real sticker travels with the parcel, and anyone can look at it. Java’s check happens only when javac compiles Java source. At run time nothing looks for the sticker, so code compiled from another JVM language, or a trick that hides the type, can throw a checked exception through a method that never declared it.
Which kind to use
The usual rule is: unchecked for programming errors, checked for conditions a caller can reasonably recover from. A null where none is allowed, an index past the end, or a negative quantity is a bug in the calling code. Forcing every caller to catch it would just add noise, so those are RuntimeExceptions. A file that isn’t there, a network that drops, or text a user typed that isn’t a valid address is something a caller can handle, by asking again or using a default. Java made those checked.
That rule is still argued about. Checked exceptions make failure visible in a method’s signature, and the compiler won’t let you forget one. But they spread through every layer of a program, they tempt people into empty catch blocks just to make the error go away, and they don’t fit lambdas. forEach(s -> IO.println(new URI(s))) fails with the same unreported exception error, because forEach takes an interface whose method declares no checked exceptions. Kotlin, Scala and C# chose not to have checked exceptions at all, and many modern Java libraries throw only unchecked ones. The JDK itself keeps using both, so you need to be comfortable with both.
try, catch and finally
A try block runs code that might throw, and each catch names a type it handles. One catch can list several types separated by |, which is called a multi-catch:
int port(String text) {
try {
int n = Integer.parseInt(text);
return List.of(80, 443, 8080).get(n);
} catch (NumberFormatException | IndexOutOfBoundsException e) {
IO.println("fallback, because " + e.getClass().getSimpleName());
return 80;
}
}
void main() {
IO.println(port("1"));
IO.println(port("one"));
IO.println(port("7"));
}
It prints:
443
fallback, because NumberFormatException
80
fallback, because ArrayIndexOutOfBoundsException
80
Look at the last lines. We asked for element 7 of a three-element List.of and expected IndexOutOfBoundsException. The list actually threw ArrayIndexOutOfBoundsException, a subclass, because it’s backed by an array. The catch still worked, because a catch handles the named type and all its subclasses. That’s a good reason to catch the documented type rather than guess the exact class.
The types in a multi-catch can’t be related. NumberFormatException | IllegalArgumentException fails with Alternatives in a multi-catch statement cannot be related by subclassing, because the parent already covers the child.
A broad catch before a narrow one doesn’t compile
The catch blocks are tried from top to bottom, and the first one whose type fits wins. So a parent type above its own subclass leaves the lower catch unreachable:
void main() {
try {
IO.println(Integer.parseInt("forty"));
} catch (RuntimeException e) {
IO.println("something went wrong");
} catch (NumberFormatException e) {
IO.println("not a number");
}
}
The build fails with:
Main.java:6: error: exception NumberFormatException has already been caught
} catch (NumberFormatException e) {
^
Put the narrow type first, then the broad one. The compiler checks the other direction too: catching a checked exception that the try body can’t throw, such as IOException around a plain IO.println, fails with exception IOException is never thrown in body of corresponding try statement.
finally always runs
A finally block runs when the try finishes, whether it finished normally, threw, or returned:
String check(String text) {
try {
IO.println("try: parsing " + text);
Integer.parseInt(text);
return "number";
} catch (NumberFormatException e) {
IO.println("catch: " + e.getMessage());
return "not a number";
} finally {
IO.println("finally: done with " + text);
}
}
void main() {
IO.println("result: " + check("42"));
IO.println("result: " + check("forty"));
}
It prints:
try: parsing 42
finally: done with 42
result: number
try: parsing forty
catch: For input string: "forty"
finally: done with forty
result: not a number
The return "number" ran first and picked the value, but the method didn’t leave until finally had printed its line. Only then did main get the result. The same happened on the path through catch.
return inside finally swallows the exception
A finally block that returns replaces whatever the try was doing, and that includes an exception on its way out. javac warns about it, but only when you ask for warnings with -Xlint. This series compiles every example with -Xlint:all -Werror, which turns the warning into a failed build:
int risky() {
try {
throw new IllegalStateException("the order was lost");
} finally {
return -1;
}
}
void main() {
IO.println("risky() returned " + risky());
IO.println("no exception reached main");
}
The build fails with:
Main.java:6: warning: [finally] finally clause cannot complete normally
error: warnings found and -Werror specified
With plain javac Main.java, or with java Main.java, there’s no warning at all, and the program runs. To show what it does, this version silences the warning with @SuppressWarnings("finally"):
@SuppressWarnings("finally")
int risky() {
try {
throw new IllegalStateException("the order was lost");
} finally {
return -1;
}
}
void main() {
IO.println("risky() returned " + risky());
IO.println("no exception reached main");
}
It prints:
risky() returned -1
no exception reached main
The IllegalStateException vanished. No stack trace, no message, and the caller got -1 as if everything were fine. Use finally for cleanup only, and never return, break or throw from it.
Throwing exceptions
A throw statement takes any Throwable object, and the most useful thing you can put in one is a message that tells the reader what was wrong. For bad arguments, the JDK gives you two tools: IllegalArgumentException, and Objects.requireNonNull for null:
record Transfer(String from, String to, long cents) {
Transfer {
Objects.requireNonNull(from, "from account is required");
Objects.requireNonNull(to, "to account is required");
if (cents <= 0) {
throw new IllegalArgumentException(
"cents must be positive, got " + cents + " for " + from + " -> " + to);
}
}
}
void main() {
IO.println(new Transfer("ana", "ben", 500));
try {
new Transfer("ana", null, 500);
} catch (NullPointerException e) {
IO.println(e.getMessage());
}
new Transfer("ana", "ben", -500);
}
It prints, then stops:
Transfer[from=ana, to=ben, cents=500]
to account is required
Exception in thread "main" java.lang.IllegalArgumentException: cents must be positive, got -500 for ana -> ben
requireNonNull returns its argument when it isn’t null, and throws NullPointerException with your message when it is. Without a message, getMessage() returns null, which helps nobody.
The IllegalArgumentException message says what the rule is, what value broke it, and which transfer it belonged to. Compare that with a bare "invalid". When this line turns up in a log, the first version tells you where to look. The part on records covers compact constructors like this one.
Wrapping an exception keeps its cause
When you catch a low-level exception and throw a more meaningful one, pass the original as the cause. Every standard exception has a constructor that takes (String message, Throwable cause):
int readPort(String text) {
try {
return Integer.parseInt(text);
} catch (NumberFormatException e) {
throw new IllegalStateException("config: port must be a number", e);
}
}
int readPortLogged(String text) {
try {
return readPort(text);
} catch (IllegalStateException e) {
IO.println("log: " + e.getMessage());
throw e;
}
}
void main() {
try {
readPortLogged("eighty");
} catch (IllegalStateException e) {
IO.println("error: " + e.getMessage());
IO.println("caused by: " + e.getCause());
}
}
It prints:
log: config: port must be a number
error: config: port must be a number
caused by: java.lang.NumberFormatException: For input string: "eighty"
Two things happen here. readPort wraps: the new exception explains the problem in terms of configuration, and getCause() still holds the original NumberFormatException. readPortLogged rethrows: it logs and then throws the same object with throw e;, so the caller sees exactly what readPort threw.
If nothing catches a wrapped exception, the stack trace prints both. After the frames for the IllegalStateException, you get a line starting Caused by: java.lang.NumberFormatException: For input string: "eighty", followed by that exception’s own frames and a line like ... 1 more for the frames the two share. Read a long trace from the last Caused by: upwards. The bottom one is usually where the trouble started.
Drop the e from the constructor and that whole second half disappears. Losing the cause is one of the most common ways a debugging session gets longer.
Custom exceptions
A custom exception is a class that extends Exception, for a checked one, or RuntimeException, for an unchecked one. Here’s the smallest version:
class InsufficientFundsException extends Exception {
InsufficientFundsException(String message) {
super(message);
}
}
void main() {
IO.println(new InsufficientFundsException("short by 30"));
}
The build fails with:
Main.java:1: warning: [serial] serializable class Main.InsufficientFundsException has no definition of serialVersionUID
error: warnings found and -Werror specified
Throwable implements Serializable, so every exception is serializable, and -Xlint:all asks each serializable class to declare a version number for its serialized form. Plain javac stays quiet, but adding the field costs one line. Notice the name in the warning, too: Main.InsufficientFundsException. In a compact source file, every class you declare sits inside a hidden class Main.
A good custom exception carries the facts a handler needs as fields, not just inside the message text:
class InsufficientFundsException extends Exception {
private static final long serialVersionUID = 1L;
final String account;
final long shortByCents;
InsufficientFundsException(String account, long shortByCents) {
super("account " + account + " is short by " + shortByCents + " cents");
this.account = account;
this.shortByCents = shortByCents;
}
}
class Account {
final String id;
long balanceCents;
Account(String id, long balanceCents) {
this.id = id;
this.balanceCents = balanceCents;
}
void withdraw(long cents) throws InsufficientFundsException {
if (cents > balanceCents) {
throw new InsufficientFundsException(id, cents - balanceCents);
}
balanceCents -= cents;
}
}
void main() {
var account = new Account("ACC-7", 1_000);
try {
account.withdraw(400);
account.withdraw(900);
} catch (InsufficientFundsException e) {
IO.println(e.getMessage());
IO.println("offer a top-up of " + e.shortByCents + " cents to " + e.account);
}
IO.println("balance: " + account.balanceCents);
IO.println(new InsufficientFundsException("X", 1));
}
It prints:
account ACC-7 is short by 300 cents
offer a top-up of 300 cents to ACC-7
balance: 600
Main$InsufficientFundsException: account X is short by 1 cents
The handler reads e.shortByCents directly. It doesn’t have to pick a number back out of a string. The second withdrawal threw before balanceCents -= cents ran, so the balance stayed at 600.
The last line shows the hidden class again. An exception’s toString uses the binary class name, Main$InsufficientFundsException, and an uncaught one prints that name in its stack trace. In a project with real files, it would be the plain class name.
Running out of money is something a caller can handle, so this one is checked. For a broken rule inside your own code, extend RuntimeException instead, and callers don’t need throws. Either way, add a constructor that takes a Throwable cause if the exception will ever wrap another.
try-with-resources closes things for you
A try-with-resources statement declares resources in brackets after try, and closes each one when the block ends, however it ends. A resource is any object that implements AutoCloseable, which has a single method, close(). Files, sockets and database connections all implement it. To keep the output predictable, this post uses a small class that prints when it opens and closes:
class Res implements AutoCloseable {
final String name;
Res(String name) {
this.name = name;
IO.println("open " + name);
}
@Override
public void close() {
IO.println("close " + name);
}
}
void main() {
try (var a = new Res("A"); var b = new Res("B")) {
IO.println("using " + a.name + " and " + b.name);
}
IO.println("after the try");
}
It prints:
open A
open B
using A and B
close B
close A
after the try
Resources open in the order you declare them and close in the reverse order. That matters when one depends on another. A buffered writer wraps a file, so the writer must flush and close before the file underneath it closes.
Two small surprises came up while writing this example with -Xlint:all:
- A resource you never use gets a warning. With an empty body, javac reports
auto-closeable resource a is never referenced in body of corresponding try statement. If you really only need the open and close, name it_, as intry (var _ = new Res("A")), and the warning goes away. close()should declare only what it throws.AutoCloseable.close()is declaredthrows Exception. WhenRes.close()kept that clause, javac warned that itcould throw InterruptedException. The fix is to declare nothing, as above, or a specific type such asIOException.
The body throws, and the resources still close
A resource is closed even when the body throws, and it’s closed before any catch of the same statement runs:
class Res implements AutoCloseable {
final String name;
Res(String name) {
this.name = name;
IO.println("open " + name);
}
@Override
public void close() {
IO.println("close " + name);
}
}
void main() {
try (var a = new Res("A"); var b = new Res("B")) {
IO.println("using " + a.name + " and " + b.name);
throw new IllegalStateException("disk full");
} catch (IllegalStateException e) {
IO.println("caught: " + e.getMessage());
}
}
It prints:
open A
open B
using A and B
close B
close A
caught: disk full
Both resources were closed before caught: disk full printed. The variables a and b aren’t even in scope inside the catch, and that’s deliberate: by the time it runs, the resources are already closed.
Watching the resources close
The order is easier to see step by step. The animation follows the program above, with its output filling in on the right:
try-with-resources opens A, then B. The body throws. Before the exception can leave the try block, B closes, then A closes, and only then does the catch block receive the exception.
Here are those steps in words, in case the animation doesn’t play for you:
- The
trystatement starts, and nothing is open yet. new Res("A")runs, printsopen A, and becomes resourcea.new Res("B")runs, printsopen B, and becomes resourceb.- The body prints
using A and B, then throwsIllegalStateException. - Before the exception leaves the
try, Java closes the resources, starting with the last one opened.b.close()printsclose B. a.close()printsclose A.- With both closed, the exception reaches
catch (IllegalStateException e), which printscaught: disk full.
When close() throws too: suppressed exceptions
If the body throws and a close() throws as well, the body’s exception wins, and the close failures are attached to it as suppressed exceptions. You read them with getSuppressed():
class Res implements AutoCloseable {
final String name;
Res(String name) {
this.name = name;
IO.println("open " + name);
}
@Override
public void close() {
IO.println("close " + name);
throw new IllegalStateException("close failed: " + name);
}
}
void main() {
try (var a = new Res("A"); var b = new Res("B")) {
IO.println("using " + a.name + " and " + b.name);
throw new RuntimeException("body failed");
} catch (RuntimeException e) {
IO.println("caught: " + e.getMessage());
for (Throwable s : e.getSuppressed()) {
IO.println("suppressed: " + s.getMessage());
}
}
}
It prints:
open A
open B
using A and B
close B
close A
caught: body failed
suppressed: close failed: B
suppressed: close failed: A
B‘s close() threw, and Java still called A‘s. Nothing was lost. The body’s exception is usually the real problem, so it’s the one you catch, and the close failures ride along in the order they happened. An uncaught exception prints them in its stack trace under Suppressed:.
Compare that with the older way, a close() call inside finally. There, an exception from close() replaces the body’s exception, and the original problem disappears, just as it did with return in finally.
Closing a variable you already have
Since Java 9, the brackets can name an existing variable instead of declaring a new one, as long as it’s final or effectively final, which means it’s never reassigned:
class Res implements AutoCloseable {
final String name;
Res(String name) {
this.name = name;
IO.println("open " + name);
}
@Override
public void close() {
IO.println("close " + name);
}
}
Res openLog() {
return new Res("log");
}
void main() {
Res log = openLog();
try (log; var _ = new Res("lock")) {
IO.println("writing to " + log.name);
}
}
It prints:
open log
open lock
writing to log
close lock
close log
We checked the version with a classic class: javac --release 8 rejects try (r) with variables in try-with-resources are not supported in -source 8, and --release 9 accepts it. Assign log a second time and javac refuses with variable log used as a try-with-resources resource neither final nor effectively final. The rule makes sure the object that gets closed is the one you meant.
Three habits to avoid
Most exception bugs come from a handful of habits, and the worst of them hides a real bug behind a catch that says nothing. This loop adds up prices and skips the ones it can’t read:
record Item(String name, String price) {}
int total(List<Item> items) {
int sum = 0;
for (var item : items) {
try {
sum += Integer.parseInt(item.price().strip());
} catch (Exception e) {
// skip prices we can't read
}
}
return sum;
}
void main() {
var items = new ArrayList<Item>();
items.add(new Item("tea", "3"));
items.add(new Item("cake", "4 euros"));
items.add(new Item("coffee", null));
items.add(new Item("water", "2"));
IO.println("total: " + total(items));
}
It prints:
total: 5
"4 euros" threw NumberFormatException, which is what the catch was written for. But the coffee’s price is null, so item.price().strip() threw NullPointerException, a different bug, and catch (Exception e) swallowed that too. javac didn’t warn about the empty block, even with -Xlint:all. That’s three mistakes in one:
- An empty catch block. At the very least, log what you skipped. If you really mean to ignore an exception, say why in a comment, and name the variable
_, as incatch (NumberFormatException _), so the reader knows it’s on purpose. - Catching
Exception. Catch the type you expect, hereNumberFormatException. Then thenullwould crash loudly on the first run, and someone would fix the data. - Using exceptions for normal control flow. If bad prices are normal input, check for them with an
iffirst. An exception should mean something unexpected happened, and building a stack trace for every row is extra work too.
What to remember
- Everything thrown extends
Throwable.Erroris for JVM trouble you don’t catch,RuntimeExceptionand its subclasses are unchecked, and every otherExceptionis checked. - The compiler makes you catch or declare a checked exception. Use unchecked for programming errors and checked for failures a caller can recover from.
- Order
catchblocks from narrow to broad.finallyalways runs, and areturnin it silently swallows exceptions. - Throw with a message that names the rule and the bad value, and pass the original exception as the cause when you wrap it.
- Give a custom exception a
serialVersionUIDand fields for the facts a handler needs. - try-with-resources closes resources in reverse order, before
catchruns, even when the body throws. Failures fromclose()become suppressed exceptions. - Don’t leave a catch block empty, and don’t catch
Exceptionto silence a problem you haven’t named.
Catch what you can handle, pass on what you can’t, and let try-with-resources do the closing.