Skip to content
SparkTime, home
Java From Zero to Job-Ready
Lessons
1 · Java Foundations
2 · Object-Oriented Java
3 · Core Java
4 · Collections and Generics
5 · Functional Java
6 · The JVM and Concurrency
7 · Tools and Craft
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
12 · Interviews and Readiness
Lesson 10 of 41

Lesson 10 · Core Java

Exceptions: Handling Errors Properly

How Java reports and handles errors: the exception hierarchy, checked versus unchecked exceptions and why opinions differ, try, catch, finally and try-with-resources, custom exceptions, exception chaining, and the habits that separate good exception handling from swallowed errors.

On this page

Key takeaways

  • An exception is an object that represents a failure and unwinds the call stack until some code handles it. Throwable has two branches: Error for serious JVM problems and Exception for conditions programs may handle.
  • Checked exceptions must be caught or declared, unchecked exceptions (RuntimeException and its subclasses) need not be. The Java tutorial advises checked for recoverable conditions and unchecked for programming errors, and many teams use unchecked exceptions almost everywhere.
  • Use try-with-resources for anything that must be closed. It closes resources in reverse order and keeps the original exception if closing also fails.
  • Catch specific exceptions, never swallow them, never both log and rethrow, and wrap lower-level exceptions in your own while keeping the original as the cause.
  • Do not use exceptions for ordinary control flow, and do not catch Throwable or Error to carry on as if nothing happened.

Things go wrong in real programs: a file is missing, the network drops, a user types "abc" where a number is expected, a database refuses a connection. A program that cannot deal with failure is a demo, not software. The question is how a language lets you deal with it.

Older languages used return codes: every function returns a status, and every caller must remember to check it. People forget, and the failure is silently ignored. Java's answer is the exception: a failure that cannot be ignored by accident, because if nobody handles it, the program stops.

What you will learn

#
  • What an exception is and how it travels through the call stack.
  • The exception hierarchy, and checked versus unchecked exceptions.
  • try, catch, finally and try-with-resources.
  • How to write and chain custom exceptions.
  • What good exception design looks like, and the common anti-patterns.

The mental model: unwinding the stack

#

When method main calls process, which calls parse, the JVM keeps a call stack: a stack of method calls in progress. Normally each method returns to the one that called it. When something goes wrong, parse can throw an exception object instead. The JVM then looks for a handler in parse. If there is none, parse is abandoned and the JVM looks in process, then in main, unwinding the stack one call at a time. If it reaches the top without finding a handler, the thread dies and prints a stack trace.

Java
// File: Main.javapublic class Main {    static int parseAge(String text) {        int age = Integer.parseInt(text);          // may throw NumberFormatException        if (age < 0) {            throw new IllegalArgumentException("age cannot be negative: " + age);        }        return age;    }    static String describe(String text) {        System.out.println("describe: start");        int age = parseAge(text);        System.out.println("describe: finished");  // skipped when parseAge throws        return "age " + age;    }    public static void main(String[] args) {        System.out.println(describe("29"));        try {            System.out.println(describe("abc"));        } catch (NumberFormatException e) {            System.out.println("handled: " + e.getMessage());        }        try {            describe("-5");        } catch (IllegalArgumentException e) {            System.out.println("handled: " + e.getMessage());        }    }}
Code
describe: startdescribe: finishedage 29describe: starthandled: For input string: "abc"describe: starthandled: age cannot be negative: -5

For "abc", the output shows describe: start but never describe: finished: the exception left describe immediately. The handler in main caught it and the program continued. An exception is a non-local jump up the call stack to the nearest matching catch.

The exception hierarchy

#

All exceptions are objects of classes that descend from java.lang.Throwable.

Code
Throwable |-- Error                      serious JVM-level problems (OutOfMemoryError, StackOverflowError) `-- Exception      |-- IOException, SQLException, ...     checked exceptions      `-- RuntimeException                   unchecked exceptions           |-- NullPointerException           |-- IllegalArgumentException, IllegalStateException           |-- ArrayIndexOutOfBoundsException, ArithmeticException, ...           `-- (your own unchecked exceptions)

The split that matters in practice is checked versus unchecked:

Checked

Unchecked

Class

Exception and subclasses, except RuntimeException

RuntimeException and Error (and subclasses)

Compiler enforcement

Must be caught or declared with throws

Not required

Typical cause

An external condition the caller might handle (IOException, SQLException)

A programming error (NullPointerException, wrong argument) or a failure the caller cannot fix

Example

Files.readString(path) can throw IOException

list.get(99) throws IndexOutOfBoundsException

Error and its subclasses signal problems with the JVM itself, such as running out of memory. Applications should not normally catch them, because there is generally nothing sensible they can do.

Checked or unchecked: why opinions differ

#

Oracle's tutorial gives a guideline: 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, make it an unchecked exception. The reasoning is that the exceptions a method can throw are part of its public interface, so callers should be forced to think about them.

That is the original design intention. Experience has produced a real debate:

For checked exceptions: they make failure modes visible in the method signature and force callers to decide. For genuinely recoverable situations (a file the user chose does not exist, so ask for another) this is useful.

Against: they spread through code. A low-level SQLException propagates through every layer's throws clause, leaking implementation details. They often tempt developers to write an empty catch just to make the compiler quiet. They do not work well with lambdas, because functional interfaces such as Function do not declare checked exceptions (article 15). And many failures are not recoverable by the immediate caller anyway.

Practical outcome: modern Java frameworks lean towards unchecked exceptions. Spring, for example, wraps the checked SQLException of JDBC in its own unchecked data-access exceptions (article 34). A good working rule: use unchecked exceptions for programming errors and for failures that the calling code cannot meaningfully fix, and consider a checked exception only when you are designing an API where callers can and should handle a specific, expected condition. The Oracle tutorial adds a warning worth remembering: do not make an exception unchecked just because you do not want to be bothered with declaring it.

try, catch, finally

#
Java
// File: Main.javapublic class Main {    static String classify(String input) {        try {            int n = Integer.parseInt(input);            return "number " + (100 / n);        } catch (NumberFormatException e) {            return "not a number";        } catch (ArithmeticException e) {            return "cannot divide by zero";        } finally {            System.out.println("finally ran for '" + input + "'");        }    }    @SuppressWarnings("finally")    static int returnInFinally() {        try {            return 1;        } finally {            return 2;            // overrides the earlier return: a trap        }    }    public static void main(String[] args) {        System.out.println(classify("4"));        System.out.println(classify("x"));        System.out.println(classify("0"));        System.out.println(returnInFinally());        try {            Object o = "text";            Integer wrong = (Integer) o;        } catch (ClassCastException | NullPointerException e) {    // multi-catch            System.out.println("multi-catch: " + e.getClass().getSimpleName());        }    }}
Code
finally ran for '4'number 25finally ran for 'x'not a numberfinally ran for '0'cannot divide by zero2multi-catch: ClassCastException

How it works:

  • The try block holds the code that may throw.
  • Each catch handles one kind of exception. The first catch whose type matches (the exception's class or a superclass) runs. Put more specific types first, because a catch for a superclass such as Exception placed first would make the later ones unreachable, and the compiler rejects that. A multi-catch (A | B) shares one handler.
  • finally runs whether or not an exception occurred, for clean-up. As the output shows, it ran in all three calls. Avoid return inside finally: it overrides the normal return and can swallow exceptions, as returnInFinally demonstrates.

try-with-resources: closing things reliably

#

Files, database connections and network sockets are resources: they hold something outside the JVM and must be closed, or they leak. Before Java 7 this meant try/finally, which is easy to get wrong. try-with-resources closes them for you. Oracle's tutorial states that any object implementing AutoCloseable (which includes everything implementing Closeable) can be used as a resource, and that each resource is closed at the end whether the statement completes normally or abruptly.

Java
// File: Main.javapublic class Main {    static class Resource implements AutoCloseable {        private final String name;        private final boolean failOnClose;        Resource(String name, boolean failOnClose) {            this.name = name;            this.failOnClose = failOnClose;            System.out.println("open " + name);        }        void use() {            System.out.println("use " + name);        }        @Override        public void close() {            System.out.println("close " + name);            if (failOnClose) {                throw new IllegalStateException("close failed for " + name);            }        }    }    public static void main(String[] args) {        try (Resource first = new Resource("A", false);             Resource second = new Resource("B", false)) {            first.use();            second.use();        }        System.out.println("---");        try (Resource only = new Resource("C", true)) {            only.use();            throw new IllegalArgumentException("problem while using C");        } catch (IllegalArgumentException e) {            System.out.println("caught: " + e.getMessage());            for (Throwable suppressed : e.getSuppressed()) {                System.out.println("suppressed: " + suppressed.getMessage());            }        }    }}
Code
open Aopen Buse Ause Bclose Bclose A---open Cuse Cclose Ccaught: problem while using Csuppressed: close failed for C

Two guarantees are visible. First, resources are closed in the reverse order of creation (B before A), because a later resource may depend on an earlier one. Second, when both the body and close() throw, the original exception from the body is the one you receive. The exception from close() is attached to it as a suppressed exception, retrievable with getSuppressed(), instead of replacing it. With a hand-written finally, the second exception would have hidden the first, which is the leak and confusion the tutorial warns about. The rule: if it has a close() method, open it in a try-with-resources.

Throwing, declaring and propagating

#

You throw an exception with throw new SomeException("message"). A method that can throw a checked exception must declare it with throws, and the caller must catch it or declare it in turn:

Java
static String readConfig(Path path) throws IOException {   // callers must handle IOException    return Files.readString(path);}

Unchecked exceptions need no declaration, though documenting them in the Javadoc is good practice.

Standard exceptions to prefer

#

Before writing your own, use the standard ones where they fit:

Exception

Use it when

IllegalArgumentException

An argument is invalid (negative amount, blank name)

IllegalStateException

The object is in the wrong state for the call (withdraw from a closed account)

NullPointerException via Objects.requireNonNull(x, "message")

A required argument is null

UnsupportedOperationException

An operation is deliberately not supported (modifying an unmodifiable list)

IndexOutOfBoundsException

An index is out of range

Custom exceptions and chaining

#

When your domain has failures that deserve a name and extra information, define your own exception. Extend RuntimeException (unchecked) unless you have decided that callers must handle it, and keep it small. Most importantly, when you catch a lower-level exception and throw a higher-level one, keep the original as the cause. This is called exception chaining.

Java
// File: Main.javapublic class Main {    static class DataAccessFailure extends RuntimeException {        DataAccessFailure(String message, Throwable cause) {            super(message, cause);        }    }    static class ApplicationNotFoundException extends RuntimeException {        private final long id;        ApplicationNotFoundException(long id) {            super("job application " + id + " was not found");            this.id = id;        }        long id() {            return id;        }    }    // Pretend low-level code that throws a technical exception    static String loadFromStorage(long id) throws java.io.IOException {        throw new java.io.IOException("disk read error for record " + id);    }    static String findApplication(long id) {        if (id <= 0) {            throw new ApplicationNotFoundException(id);        }        try {            return loadFromStorage(id);        } catch (java.io.IOException e) {            throw new DataAccessFailure("could not load application " + id, e);   // keep the cause        }    }    public static void main(String[] args) {        try {            findApplication(0);        } catch (ApplicationNotFoundException e) {            System.out.println("not found: id=" + e.id() + " (" + e.getMessage() + ")");        }        try {            findApplication(7);        } catch (DataAccessFailure e) {            System.out.println(e.getMessage());            System.out.println("caused by: " + e.getCause().getClass().getSimpleName() + ": " + e.getCause().getMessage());        }    }}
Code
not found: id=0 (job application 0 was not found)could not load application 7caused by: IOException: disk read error for record 7

The higher layer sees a meaningful, domain-level exception (DataAccessFailure, ApplicationNotFoundException) and not a raw IOException from the layer below. But the original IOException survives as the cause, so the full story appears in the stack trace when you log it. Always pass the cause: throwing a new exception without it destroys the evidence you need to debug. In the REST API project you will map ApplicationNotFoundException to an HTTP 404 response in one central place (article 33).

Good exception design

#

Do:

  • Fail fast. Validate arguments at the start of a method and throw immediately, rather than letting bad data travel and fail somewhere confusing.
  • Be specific. Catch the narrowest exception you can handle, and throw the most precise one that fits.
  • Include context in the message: what you were doing and the values involved (but never passwords or secrets).
  • Preserve the cause when wrapping.
  • Handle an exception at the level that can do something about it, which is often much higher than where it was thrown. Let it propagate until then.
  • Use try-with-resources for anything closeable.
  • Log an exception once, at the place where you handle it (see article 22).

Do not:

  • Swallow exceptions. catch (Exception e) { } hides failures, and the program carries on in an unknown state. If you truly intend to ignore one, say why in a comment, and ideally log it.
  • Catch Exception or Throwable broadly just to make errors disappear. At the very top of a thread or request, a catch-all that logs and returns an error response is acceptable. In the middle of business logic it is not.
  • Both log and rethrow the same exception at every layer. The same stack trace then appears five times in the log, making the real problem hard to find.
  • Use exceptions for normal control flow, such as using an exception to leave a loop or to signal "not found" in a hot path. They are harder to read and slower than ordinary conditions.
  • Return null to indicate failure when an exception, an empty result or an Optional communicates better (article 17).
  • Catch an Error (such as OutOfMemoryError) and carry on as if nothing happened.

For failures that are expected and frequent (user input that does not parse, a search with no result), a return type that models the alternatives can be a clearer API than an exception. Remember the sealed ParseResult from article 9. The skill is to choose: exceptions for exceptional or programming-error conditions, and ordinary return values for ordinary outcomes.

Common mistakes

#
  • Empty catch blocks.
  • Catching Exception when a specific exception is meant.
  • Losing the cause when wrapping an exception.
  • Logging and rethrowing at every level.
  • Putting return in finally.
  • Forgetting to close resources, or closing them manually in the wrong order.
  • Using printStackTrace() as error handling in application code. It goes to standard error and bypasses your logging.
  • Over-using checked exceptions, forcing every caller to handle something they cannot fix.
  • Throwing Exception or RuntimeException directly, which gives callers nothing specific to catch.

Exercises

#

Exercise 1

Trace the flow

#

What does this print? try { System.out.println("a"); int x = 1 / 0; System.out.println("b"); } catch (ArithmeticException e) { System.out.println("c"); } finally { System.out.println("d"); } System.out.println("e");

Exercise 2: Fix the catch order

#

The following does not compile: try { ... } catch (Exception e) { ... } catch (IOException e) { ... }. Why, and how do you fix it?

Exercise 3: Close the resources

#

Rewrite a method that opens a BufferedReader in a try block and closes it in finally (checking for null first) using try-with-resources, and explain what the new version does if both reading and closing fail.

Exercise 4: Find the problem

#

A service method contains try { repository.save(order); } catch (Exception e) { log.error(e.getMessage()); } and then returns a success response. List what is wrong.

Exercise 5: Design an exception

#

You are building the transfer-money feature. Design the exceptions for "insufficient funds" and "account not found". Decide checked or unchecked, what they carry, and where they are handled.

Show solutionHide solution

It prints a, c, d, e, each on its own line. a prints first, then 1 / 0 throws ArithmeticException, so b is skipped, the catch block prints c, finally prints d, and execution continues after the try statement with e.

Exercise 2

Show solutionHide solution

IOException is a subclass of Exception, so the first catch already handles it, and the second is unreachable. The compiler reports an error. Put the more specific type first: catch (IOException e) { ... } catch (Exception e) { ... }. In general, order catch blocks from the most specific to the most general.

Exercise 3

Show solutionHide solution
Java
try (BufferedReader reader = new BufferedReader(new FileReader(path))) {    return reader.readLine();}

The reader is closed automatically, even when readLine throws. If both reading and closing fail, the exception from reading is the one that propagates, and the one from closing is added as a suppressed exception (getSuppressed()). With the hand-written finally, a failure in close() would replace the original exception and hide the real problem.

Exercise 4

Show solutionHide solution

Several things are wrong: it swallows the failure, so the caller gets a success response even though the order was not saved. It catches Exception broadly, including programming errors. It logs only the message, losing the stack trace and cause. The fix is to let the exception propagate (or wrap it in a meaningful exception with the cause) and let a central handler turn it into an error response. If you must catch it, log it with the stack trace and return a failure, not a success.

Exercise 5

Show solutionHide solution

A reasonable design: both as unchecked exceptions extending RuntimeException: InsufficientFundsException carrying the account id, the requested amount and the available balance; and AccountNotFoundException carrying the id. The service layer throws them. They are handled once, at the boundary of the application: in a command-line program, the main loop prints a friendly message, and in a web API a central exception handler maps them to HTTP responses (for example 409 and 404). A team that prefers checked exceptions could make InsufficientFundsException checked, because a caller can reasonably recover (offer a smaller amount), but then every layer between must declare it. Either choice is defensible, so explain the trade-off.

Where to go next

#

Sources & further reading

  1. docs.oracle.com
  2. docs.oracle.com
  • Guide · Technology

    Java Essentials for DSA

  • Guide · Technology

    Heaps and Priority Queues

  • Course · Technology

    DSA From Zero to Job-Ready: A Practical Guide with Java