Exception Handling in Java: Best Practices and Common Mistakes
A catch (Exception e) { } block with a // TODO comment compiles without a single warning and easily survives code review. The program below honestly reports that there were no errors, even though one of three orders was never saved — and neither the console nor the log says a word about it.
import java.util.List;
public class SwallowedDemo {
static int saved = 0;
static void save(String order) {
if (order.isBlank()) {
throw new IllegalArgumentException("Empty order");
}
saved++;
}
public static void main(String[] args) {
List<String> orders = List.of("A-1", " ", "A-3");
for (String order : orders) {
try {
save(order);
} catch (Exception e) {
// TODO: handle later
}
}
System.out.println("Processing finished, no errors");
System.out.println("Saved: " + saved + " of " + orders.size());
}
} Processing finished, no errors
Saved: 2 of 3 Java exception handling best practices are a set of rules that keep an error from being lost or duplicated: catch an exception only where you can react to it meaningfully, catch the most specific type, log it once together with the stack trace, keep the original cause when you change the exception type, and close resources with try-with-resources. Breaking these rules is one of the most frequent topics of Java exception handling interview questions. Every practice in this lesson follows the same pattern: bad code, what exactly breaks, correct code.
Do not swallow exceptions
Exception swallowing means catching an exception and telling nobody about it: leaving the catch block empty, only calling printStackTrace(), or returning null or -1 instead of reporting the error. The program keeps running in a wrong state, and the real cause of the failure surfaces later, somewhere else entirely.
Empty catch block
In the example at the top of the lesson, the empty catch turned an error into silence: the counter shows 2 of 3, but no caller knows which order failed or why. If the failure of one element really must not stop the loop, at least record it — collect it in a list, write it to the log or return it in a report:
import java.util.ArrayList;
import java.util.List;
public class ReportedDemo {
static void save(String order) {
if (order.isBlank()) {
throw new IllegalArgumentException("Empty order");
}
}
public static void main(String[] args) {
List<String> orders = List.of("A-1", " ", "A-3");
List<String> failed = new ArrayList<>();
for (int i = 0; i < orders.size(); i++) {
try {
save(orders.get(i));
} catch (IllegalArgumentException e) {
failed.add("#" + (i + 1) + ": " + e.getMessage());
}
}
System.out.println("Saved: " + (orders.size() - failed.size()) + " of " + orders.size());
System.out.println("Errors: " + failed);
}
} Saved: 2 of 3
Errors: [#2: Empty order] Note that catch (Exception e) was also replaced with the specific IllegalArgumentException — why that matters is covered in the next section. A beginner version of the same mistake (a parser that silently returns 0) is shown in Mistakes beginners make with exceptions.
A catch block with only printStackTrace()
try {
saveOrder(order);
} catch (SQLException e) {
e.printStackTrace(); // execution continues as if the order were saved
}
sendConfirmationEmail(order); This version looks «handled», but it breaks two things. First, the method keeps going: the customer receives a confirmation email for an order that is not in the database. Second, printStackTrace() writes to System.err, bypassing the logging system — no timestamp, no level, no request id, no file rotation, and in a container or on an application server this output is often simply lost. The right move is not to catch the exception where there is nothing to do with it, or to translate it into an exception of your own layer (see wrapping).
Returning null or -1 instead of an error
// Bad: a disk failure looks like "user not found"
static User findUser(String id) {
try {
return storage.load(id);
} catch (IOException e) {
return null;
}
} Two different situations — «there is no user with this id» and «the storage is unavailable» — collapse into a single null. The calling code either forgets the check and gets a NullPointerException a dozen lines later, or shows the user «account not found» during a server outage. With -1 it is even worse: it can be a perfectly valid value (a temperature, an offset, a balance). Express a missing result with Optional, and a failure with an exception:
// Good: "no user" is Optional.empty(), a storage failure is an exception
static Optional<User> findUser(String id) throws IOException {
return storage.find(id);
} When ignoring is really intended
Some exceptions are ignored on purpose: for example, a failure while closing a resource you no longer need. Then make the intent explicit — name the parameter ignored and explain in a comment why it is safe. In Java 22+ you can use an unnamed variable: catch (NumberFormatException _) (JEP 456). An empty catch without an explanation will rightly be read as a bug.
Why not catch Exception, Throwable or Error
A catch (Exception e) block catches not only the error you expected but every RuntimeException as well — that is, bugs like NullPointerException and ClassCastException — plus the checked InterruptedException. catch (Throwable t) adds OutOfMemoryError and StackOverflowError on top. Catch the most specific type you know how to handle.
public class BroadCatchDemo {
static int parseOrDefault(String s, int def) {
try {
return Integer.parseInt(s.trim());
} catch (Exception e) {
return def;
}
}
public static void main(String[] args) {
System.out.println(parseOrDefault("42", 0));
System.out.println(parseOrDefault("abc", 0));
System.out.println(parseOrDefault(null, 0));
}
} 42
0
0 The third call is a programmer mistake: null was passed in, and s.trim() threw a NullPointerException. But catch (Exception e) treated it as «a string in the wrong format» and returned the default value. The bug is masked and will surface wherever that zero leads to a wrong calculation. The fix is to catch exactly what you expect:
} catch (NumberFormatException e) {
return def;
} Now parseOrDefault(null, 0) fails immediately with a NullPointerException and a stack trace that points to the calling code. If the try block can throw several expected exceptions, list them with multi-catch: catch (IOException | ParseException e).
What else broad catch blocks catch along the way:
| Block | What it also catches | What breaks |
|---|---|---|
catch (RuntimeException e) | NullPointerException, ClassCastException, IndexOutOfBoundsException, ConcurrentModificationException | Bugs are disguised as an expected situation, and the program keeps working with wrong data |
catch (Exception e) | Everything above + the checked InterruptedException | The thread interrupt flag is lost, and the thread cannot be stopped cleanly (see the InterruptedException section) |
catch (Error e) | OutOfMemoryError, StackOverflowError, NoClassDefFoundError, AssertionError | JVM and environment failures are hidden, and so are failed assert statements in tests |
catch (Throwable t) | Everything at once: both Exception and Error | After an OutOfMemoryError the program state is not guaranteed: carrying on as if nothing happened is dangerous |
Why catch (Exception e) does not catch StackOverflowError is shown with an example in catch (Exception e) and StackOverflowError.
Where catch (Exception e) is fine: the application boundary
A broad catch is justified at the application boundary — the topmost point where one failed operation must not stop everything else: the main method, an HTTP request handler, the loop of a worker thread that processes a task queue. There the exception is logged, and the code moves on to the next task or returns an HTTP 500 error to the client.
import java.util.List;
public class WorkerLoop {
static void process(String task) {
if (task.equals("bad")) {
throw new IllegalStateException("Could not process " + task);
}
System.out.println("Done: " + task);
}
public static void main(String[] args) {
for (String task : List.of("t1", "bad", "t3")) {
try {
process(task);
} catch (Exception e) { // boundary: one task must not stop the rest
System.out.println("Task skipped: " + e);
}
}
}
} Done: t1
Task skipped: java.lang.IllegalStateException: Could not process bad
Done: t3 In production code, System.out.println here becomes a logger call that receives e — exactly how is covered in the next section. In Spring such a boundary is usually a single @ControllerAdvice handler instead of a try-catch in every controller.
Logging exceptions: once and with the stack trace
An exception should be either logged or rethrown — not both: every layer that logs and rethrows adds one more copy of the same error to the log. Log it once, where the exception is finally handled, and pass the exception object itself to the logger together with the context of the operation.
The log-and-rethrow anti-pattern: duplicate log entries
A repository, a service and a controller each write the error to the log «just in case» and pass it up. The example uses the standard java.util.logging.Logger; the handler in the static block prints each record to the console on one line only to make the output identical on every run (by default java.util.logging writes to System.err with a date and time).
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
public class LogAndRethrowDemo {
private static final Logger log = Logger.getLogger("demo");
static {
log.setUseParentHandlers(false);
log.addHandler(new Handler() {
@Override
public void publish(LogRecord record) {
System.out.println(record.getLevel() + ": " + record.getMessage()
+ " | " + record.getThrown());
}
@Override
public void flush() {
}
@Override
public void close() {
}
});
}
static void loadFromDb() {
try {
throw new IllegalStateException("DB connection is closed");
} catch (IllegalStateException e) {
log.log(Level.SEVERE, "Error in repository", e);
throw e;
}
}
static void loadOrder() {
try {
loadFromDb();
} catch (IllegalStateException e) {
log.log(Level.SEVERE, "Error in service", e);
throw e;
}
}
public static void main(String[] args) {
try {
loadOrder();
} catch (IllegalStateException e) {
log.log(Level.SEVERE, "Could not load order", e);
}
}
} SEVERE: Error in repository | java.lang.IllegalStateException: DB connection is closed
SEVERE: Error in service | java.lang.IllegalStateException: DB connection is closed
SEVERE: Could not load order | java.lang.IllegalStateException: DB connection is closed One error — three SEVERE entries, and in a real log three full stack traces as well. The monitoring system counts three incidents, the on-call engineer looks for three causes, and when searching the log it is hard to tell where the error first occurred. The correct approach: intermediate layers either do nothing with the exception or wrap it with added context, and only the top layer logs it — the application boundary from the previous section.
The one-log rule
Whoever handles the exception logs it. If a method has a throw in its catch block, it is not handling the error but passing it on, and the log.error(...) line in front of it is almost always redundant. The exception to the rule is a system boundary the stack trace will not cross: for example, a response to an external client that receives only an error code.
Pass the exception to the logger, not e.getMessage()
The exception message is only a small part of the information it carries: it has no stack trace and no Caused by chain, and sometimes there is no text at all.
import java.io.IOException;
public class MessageOnlyDemo {
public static void main(String[] args) {
Exception[] errors = {
new IllegalStateException(),
new RuntimeException("Write failed", new IOException("Disk full"))
};
for (Exception e : errors) {
System.out.println("Error: " + e.getMessage());
}
}
} Error: null
Error: Write failed The first log line is useless, and the second has lost the real cause — «Disk full». Pass the exception as the last argument, and the logger prints the class, the message, the stack trace and all causes for you:
// Bad
log.severe("Error: " + e.getMessage()); // stack trace and cause are lost
System.out.println("Error: " + e); // never reaches the log file
e.printStackTrace(); // System.err, no timestamp or level
// Good: java.util.logging (part of the JDK)
log.log(Level.SEVERE, "Failed to save order id=" + orderId, e);
// Good: SLF4J (external library org.slf4j, runs on top of Logback or Log4j 2)
log.error("Failed to save order id={}", orderId, e); In SLF4J an exception passed as the last argument, after all the values for {}, is recognized as a Throwable, and the full stack trace is printed.
Context in the message
A log message should answer the question «what were we trying to do, and with which data»: "Failed to save order id=42 for customer 7" instead of "Error" or "Exception in save". The logger adds the exception type and the stack trace on its own. Never put passwords, card numbers or tokens in the message: more people read logs than read the database.
Checked vs unchecked exceptions: when to use which
Choose a checked exception when the situation is expected and the calling code can react to it meaningfully: retry the request, take another path, show the user a clear message. Choose an unchecked exception when it is a programmer mistake or a broken method contract — something to fix in the code, not to handle.
- Checked: the cause is external and does not depend on the calling code — a file is not found, a payment is declined by the bank, a service did not respond in time. The compiler forces every caller to make a decision.
- Unchecked: a
nullor an invalid argument was passed, a method was called at the wrong time, an object invariant is broken. Such an error is fixed by changing the code, not with acatchblock. - A practical test: if almost every caller writes
catch (XException e) { throw new RuntimeException(e); }, the exception should have been unchecked.
Which class to extend in each case, with a summary table, is in Extends Exception vs extends RuntimeException; which JDK exceptions are checked is in Checked vs unchecked exceptions in Java.
Why Spring and other frameworks choose unchecked
Checked exceptions work well within one layer but scale poorly across layers. A SQLException from a repository requires throws SQLException in the service, then in the controller — and JDBC details leak through the whole application. Every layer is forced either to declare an exception that is not its own or to write a catch that often turns into swallowing. That is why Spring translates SQLException into the unchecked DataAccessException hierarchy, and Hibernate and JPA throw PersistenceException — also unchecked.
One more practical detail that comes up in interviews: Spring @Transactional by default rolls back a transaction only on RuntimeException and Error. If a service throws a checked exception, the data is committed unless you explicitly specify @Transactional(rollbackFor = Exception.class). Kotlin, Scala and C# have no checked exceptions at all.
Checked exceptions in lambdas and the Stream API
The functional interfaces Function, Consumer and ToIntFunction do not declare throws, so a checked exception cannot leave a lambda: skus.stream().mapToInt(sku -> price(sku)) fails to compile with unreported exception PriceException; must be caught or declared to be thrown. You have to catch it inside the lambda and wrap it in an unchecked one:
import java.util.List;
public class CheckedInLambdaDemo {
static class PriceException extends Exception {
PriceException(String message) {
super(message);
}
}
static int price(String sku) throws PriceException {
if (sku.startsWith("X")) {
throw new PriceException("No price for " + sku);
}
return sku.length() * 100;
}
public static void main(String[] args) {
List<String> skus = List.of("AB", "ABC", "X1");
try {
int total = skus.stream()
.mapToInt(sku -> {
try {
return price(sku);
} catch (PriceException e) {
throw new IllegalStateException(e);
}
})
.sum();
System.out.println("Total: " + total);
} catch (IllegalStateException e) {
System.out.println("Error: " + e.getCause().getMessage());
}
}
} Error: No price for X1 Five lines of boilerplate for one call — this is the main complaint against checked exceptions in modern code. For IOException the JDK has a ready-made wrapper, UncheckedIOException (Java 8+), see also the forEach question in the throw and throws FAQ. If your method is mostly called from lambdas, that is a strong argument for making its exception unchecked.
Wrapping an exception and keeping the cause
When an exception crosses a layer boundary (repository, service, API), it is translated into an exception of that layer — exception translation — and the original exception must be passed as the second constructor argument. The calling code then sees a type it understands, and the log keeps the full Caused by chain down to the root cause.
// Bad: the cause is lost, the log will show neither SQLException nor its stack trace
} catch (SQLException e) {
throw new OrderStorageException("DB error");
}
// Bad: the type and stack trace of the original exception are lost, only the text remains
} catch (SQLException e) {
throw new RuntimeException(e.getMessage());
}
// Good: your own type, context and the cause
} catch (SQLException e) {
throw new OrderStorageException("Failed to save order id=" + order.getId(), e);
} Do not wrap without a reason: if the exception already has a suitable type, a wrapper only makes the stack trace longer. Rethrowing the same object with throw e is covered in Rethrowing an exception, and the constructors with a cause parameter, without which wrapping is impossible, in Exception constructors: message and cause.
Do not use exceptions for control flow
An exception reports a situation the method cannot handle; it does not replace ordinary branching. If a situation can be checked in advance with a simple condition — the array length, a key in a map, hasNextInt() on a Scanner — check the condition: such code is faster, shorter and does not mask real errors.
// Bad: leaving the loop via an exception
int i = 0;
int sum = 0;
try {
while (true) {
sum += data[i++];
}
} catch (ArrayIndexOutOfBoundsException e) {
// end of array
}
// Good
int sum = 0;
for (int value : data) {
sum += value;
} In the first version the catch also catches a genuine out-of-bounds access somewhere deeper in the called code and mistakes it for «end of array». The same goes for try { map.get(key).process(); } catch (NullPointerException e) instead of containsKey() or getOrDefault().
The cost of an exception. When an exception object is created, the Throwable constructor calls fillInStackTrace() and walks the entire current call stack, so an exception costs much more than an if check — the deeper the stack, the more expensive, and in framework-based applications it is often a hundred frames deep. In a hot loop this is noticeable. Filling in the stack trace can be turned off with a special constructor (see the constructor table), but that is justified only in rare cases.
The line here is pragmatic: if you cannot check in advance, or the check is more complex than the operation itself, an exception is appropriate. For example, the JDK has no method that answers «can this string be parsed as a number?», so catch (NumberFormatException e) at the user input boundary is normal practice (an example is in NumberFormatException). Files are similar: checking Files.exists() before reading does not protect you from IOException, because the file can be deleted between the check and the open.
Resources and finally: try-with-resources
Close files, connections and streams with try-with-resources, not manually in finally. If close() in finally throws an exception, it replaces the original exception from the try block, and the real cause of the failure disappears. Try-with-resources keeps the original exception and attaches the close failure to it as a suppressed exception.
public class ResourceErrorsDemo {
static class Connection implements AutoCloseable {
@Override
public void close() {
throw new IllegalStateException("Error on close");
}
}
static void withFinally() {
Connection c = new Connection();
try {
throw new IllegalArgumentException("Processing error");
} finally {
c.close();
}
}
static void withResources() {
try (Connection c = new Connection()) {
throw new IllegalArgumentException("Processing error");
}
}
public static void main(String[] args) {
try {
withFinally();
} catch (RuntimeException e) {
System.out.println("finally: " + e.getMessage()
+ ", suppressed count: " + e.getSuppressed().length);
}
try {
withResources();
} catch (RuntimeException e) {
System.out.println("try-with-resources: " + e.getMessage()
+ ", suppressed: " + e.getSuppressed()[0].getMessage());
}
}
} finally: Error on close, suppressed count: 0
try-with-resources: Processing error, suppressed: Error on close With a manual finally, the caller learned only about the secondary close failure, and «Processing error» vanished without a trace. For the same reason, never put throw or return in finally: a return in finally cancels the exception completely, and the method completes as if it succeeded — see the example in Try Catch Finally and Return: Tricky Cases. The syntax, multiple resources and their closing order are covered in Try-with-Resources.
InterruptedException: do not lose the interrupt flag
InterruptedException means that another thread asked the current one to stop. When methods such as Thread.sleep() and wait() throw it, they clear the interrupt flag, so an empty catch erases that request without a trace. After catching the exception, either propagate it with throws or restore the flag by calling Thread.currentThread().interrupt().
public class InterruptDemo {
static void pause() {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
// bad: the interrupt flag is cleared and lost
}
}
static void pauseCorrect() {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // restore the flag
}
}
public static void main(String[] args) {
Thread.currentThread().interrupt();
pause();
System.out.println("After pause(): " + Thread.currentThread().isInterrupted());
Thread.currentThread().interrupt();
pauseCorrect();
System.out.println("After pauseCorrect(): " + Thread.currentThread().isInterrupted());
}
} After pause(): false
After pauseCorrect(): true If the flag is set before the call, Thread.sleep() throws InterruptedException immediately and clears the flag. After pause(), code higher up the stack can no longer tell that the thread was asked to stop: a while (!Thread.currentThread().isInterrupted()) loop in a worker keeps spinning, and ExecutorService.shutdownNow(), which stops tasks precisely through interrupt(), cannot shut the pool down. A catch (Exception e) around code with sleep() or BlockingQueue.take() has the same effect — so such a block needs a separate catch (InterruptedException e) placed before it.
Fail fast: validate arguments at the start of a method
The fail-fast principle: validate arguments in the first lines of a method or constructor and throw an exception with a clear message right away. The error then shows up where the invalid data appeared, not ten calls later as a mysterious NullPointerException or an object in a corrupted state.
public Order(String customer, int quantity) {
this.customer = Objects.requireNonNull(customer, "customer");
if (quantity <= 0) {
throw new IllegalArgumentException("quantity must be greater than 0: " + quantity);
}
this.quantity = quantity;
} Objects.requireNonNull() throws a NullPointerException with the given text, and IllegalArgumentException is the standard signal of an invalid value. Which standard exception to choose for each situation is shown in The throw statement and throw new, and how such checks prevent common exceptions is covered in IllegalArgumentException and How to read the message and prevent these exceptions.
Final checklist: bad vs good
The checklist collects every practice of the lesson in one table: the first column is the code to look for in a code review, the second is what to replace it with, the third is what exactly breaks if you leave it as is. A detailed breakdown with examples is in the linked section.
| Bad | Good | Why |
|---|---|---|
An empty catch (Exception e) { } | Do not catch, or handle a specific type: details | The error disappears, and the program runs in a wrong state |
A catch with only e.printStackTrace() | Rethrow, or log through a logger with e | Output to System.err gets lost, and execution goes on |
return null or -1 on failure | Optional for a missing value, an exception for a failure | «No data» and «outage» are indistinguishable, an NPE comes later |
catch (Exception e) around business logic | A specific type or multi-catch; Exception only at the application boundary | Bugs (NPE, ClassCast) are disguised as an expected error |
catch (Throwable t) / catch (Error e) | Do not catch Error in application code | After an OutOfMemoryError the JVM state is not guaranteed |
log.error(...) followed by throw e | Either log or rethrow: details | One error — several entries and stack traces in the log |
log.error(e.getMessage()) | log.log(Level.SEVERE, "context", e) | The stack trace and the Caused by chain are lost, the message may be null |
throw new XException("...") in catch without e | throw new XException("...", e) | The chain of causes is broken |
| A checked exception for a programmer mistake | Unchecked for a broken contract: details | Callers write useless catch blocks, lambdas fill up with boilerplate |
catch (ArrayIndexOutOfBoundsException e) to leave a loop | A condition check, for-each | Expensive, and it hides genuine out-of-bounds bugs |
Manual close() in finally | try-with-resources | The close exception replaces the original one |
return or throw in finally | Only resource cleanup in finally | The original exception is lost |
An empty catch (InterruptedException e) | Thread.currentThread().interrupt() or throws | The thread cannot be stopped cleanly |
| Arguments are not validated, an NPE deep in the call chain | Objects.requireNonNull(), IllegalArgumentException at the start of the method | The error shows up where the bad data entered |
Frequently Asked Questions
Is it OK to catch Throwable in Java?
It compiles, but application code almost never needs it: together with exceptions it catches OutOfMemoryError and StackOverflowError, after which it is unsafe to carry on. The justified case is the very top level of a thread or a framework, where the error must be logged before shutting down; after that, an Error should be rethrown rather than ignored. What each broad catch catches is in the table in Why not catch Exception, Throwable or Error.
What is exception swallowing and how do I find it in existing code?
Exception swallowing is catching an exception without any reaction: an empty catch, a catch with only printStackTrace(), or replacing the error with null. Tools find it quickly: IntelliJ IDEA highlights an empty catch block with an inspection, and SonarQube and other static analyzers flag empty and overly broad catch blocks. By hand, look for a catch followed by neither a throw nor a logger call with e.
What should an intermediate layer do: log the exception and rethrow it?
No, that is the log-and-rethrow anti-pattern: every layer adds its own copy of the error to the log. If the intermediate layer has something to add, it wraps the exception in its own type, puts the context (order id, file name) in the message and passes the original exception as the cause — throw new OrderStorageException("... id=" + id, e). The top layer logs the whole chain once. An example with the duplicates in the output is in The log-and-rethrow anti-pattern.
Checked or unchecked exception for my API: how do I decide quickly?
Ask: can the caller do anything sensible other than abort the operation? If yes, and the cause is external (network, file, a declined payment), make it checked. If it is a bug in the code or a broken contract, make it unchecked. In doubtful cases modern libraries and frameworks choose unchecked, especially if the method is called from lambdas. Details are in Checked vs unchecked exceptions: when to use which.
What should I throw for a null argument: NullPointerException or IllegalArgumentException?
The JDK convention is NullPointerException, usually via Objects.requireNonNull(arg, "arg"); the standard library methods do the same. IllegalArgumentException is thrown for values that are not null but are invalid: a negative quantity, an empty string, a date in the past. What matters most is validating the argument at the start of the method and naming the parameter in the message.
Does parsing input with try-catch break the rule «no exceptions for control flow»?
No. The rule is about situations that are cheap to check in advance with a condition. For a string from the user the JDK has no «can this be parsed» method, so catch (NumberFormatException e) around Integer.parseInt() at the input boundary is normal practice. It would be wrong to catch the exception deep in business logic or in a hot loop, where the data should already be validated.
Comments