Back to Blog
Java

Java Exceptions: Handling Errors Without Losing Context

java exceptions: Learn how to handle Java exceptions correctly: checked vs unchecked, try-with-resources, custom exceptions, and common pitfalls.

exception handlingchecked exceptionsunchecked exceptionstry-with-resourcescustom exceptionsJava error handling
Illustration of a Java exception being caught and handled with a try-catch block, showing the call stack and error propagation.

When a method fails in Java, the runtime throws an exception that carries the failure up the call stack. How you design that propagation determines whether the error is recoverable, logged, or silently swallowed. Java exceptions are not just a syntax feature; they are a contract between the caller and the callee about what can go wrong and how to respond.

The Exception Hierarchy and How the JVM Uses It

All exceptions and errors in Java descend from Throwable. The JVM throws a Throwable when something abnormal occurs, and it unwinds the stack until a matching catch block is found. The hierarchy is deliberately split into three branches:

  • Error – serious problems that the application usually cannot handle, such as OutOfMemoryError or StackOverflowError. You should not catch these.
  • Exception – conditions that a well-written application might want to handle. This branch includes RuntimeException and checked exceptions.
  • RuntimeException – unchecked exceptions that occur at runtime, such as NullPointerException or IllegalArgumentException. The compiler does not force you to catch them.
TypeChecked?Typical handling
ErrorNoDo not catch; let the JVM terminate
Exception (except RuntimeException)YesMust catch or declare in throws
RuntimeExceptionNoOptional; often indicates programmer error

The distinction matters because it tells the caller what they are expected to do. A checked exception forces the caller to acknowledge the failure; an unchecked exception does not. Choosing the right branch for a custom exception is a design decision, not an afterthought.

Checked vs Unchecked Exceptions: The Compiler's Role

The compiler enforces checked exceptions at compile time. If a method can throw a checked exception, it must either catch it or declare it with throws. This is a static contract that makes the failure mode visible in the method signature. For example, reading a file can throw an IOException, which is checked:

public String readFirstLine(String path) throws IOException { try (BufferedReader reader = new BufferedReader(new FileReader(path))) { return reader.readLine(); } }

Unchecked exceptions, on the other hand, are not part of the method signature. They can propagate without any declaration. This is useful for programming errors that should never happen in normal operation, but it also means the caller may not know about them until they occur. The rule of thumb is: use checked exceptions for conditions that a caller can reasonably recover from, and use unchecked exceptions for conditions that indicate a bug or a broken precondition.

Writing Try-Catch-Finally Blocks That Preserve the Original Error

The classic try-catch-finally block gives you a place to handle the exception and a guaranteed block for cleanup. The finally block runs whether an exception is thrown or not, which makes it suitable for releasing resources that are not managed automatically. However, a common mistake is to throw a new exception from the catch block without preserving the original cause. That loses the stack trace that explains why the failure happened.

try { riskyOperation(); } catch (IOException e) { throw new ApplicationException("Failed to run operation", e); // cause preserved } finally { cleanup(); // runs even if the catch block throws }

When you wrap an exception, always pass the original as the cause parameter. The Throwable constructor stores it, and logging frameworks can print the full chain. If you rethrow the same exception, use throw e; rather than creating a new one, unless you need to add context. If you must throw a different type, include the original cause to avoid hiding the root failure.

Try-With-Resources: Closing Resources Automatically

Since Java 7, the try-with-resources statement simplifies resource management for objects that implement AutoCloseable. Instead of manually closing in a finally block, you declare the resource in the try header and the JVM closes it automatically. This reduces boilerplate and prevents resource leaks when an exception occurs during the try body.

try (Connection conn = dataSource.getConnection(); PreparedStatement stmt = conn.prepareStatement(sql)) { // use the connection and statement } catch (SQLException e) { // handle the database error }

If both the try body and the close method throw exceptions, the one from the try body is propagated, and the close exception is added as a suppressed exception. You can retrieve suppressed exceptions with getSuppressed() when you need to diagnose cleanup failures. Use this construct for any resource that implements AutoCloseable, such as streams, readers, or network connections.

Creating Custom Exceptions That Carry Useful Context

Custom exceptions let you attach domain-specific information to a failure. Instead of throwing a generic Exception with a vague message, define a class that carries the fields needed for logging or recovery. Decide whether it should be checked or unchecked based on how the caller is expected to react.

public class OrderNotFoundException extends RuntimeException { private final String orderId; public OrderNotFoundException(String orderId) { super("Order not found: " + orderId); this.orderId = orderId; } public String getOrderId() { return orderId; } }

Extend RuntimeException when the failure is likely a programming error or when you do not want to force every caller to handle it. Extend Exception when you want the compiler to remind callers to deal with the condition. In either case, include a constructor that accepts a cause so you can chain the original exception. Keep the class small and focused; a custom exception should not carry business logic, only the data needed to understand and respond to the failure.

Performance and Maintainability Considerations in Exception Handling

Creating an exception is not free. The JVM fills in the stack trace when the exception is constructed, which involves capturing the current call stack. In hot paths, excessive exception creation can add measurable overhead. Modern JVMs optimize the common case, but you should still avoid using exceptions for control flow. For example, do not throw an exception to signal that a value is missing when a null or an Optional would be more appropriate.

Maintainability suffers when exception handling is scattered and inconsistent. Define a clear policy for your codebase: which exceptions are checked, how they are logged, and what the caller is expected to do. Prefer specific exception types over a generic Exception catch. When logging, include the stack trace and any contextual fields from the exception object. This makes production debugging significantly easier.

Common Pitfalls That Hide Real Failures

An empty catch block is the most direct way to hide a bug. It swallows the exception and leaves the system in an unknown state. If you must ignore an exception, at least log it at a level that will be visible during development. Another pitfall is catching Exception or Throwable broadly, which can mask unexpected errors like NullPointerException or OutOfMemoryError. Catch only the types you can handle.

A subtle issue occurs when rethrowing an exception without preserving the stack trace. If you write throw new Exception(e.getMessage()); you lose the original stack. Always pass the original exception as the cause. Also be careful with InterruptedException: catching it and not restoring the interrupt flag breaks thread cooperation. The correct pattern is to re-interrupt the thread:

try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); // restore the flag // handle the interruption }

These pitfalls are easy to introduce but have long-term consequences. A disciplined approach to Java exceptions—choosing the right type, preserving the cause, and closing resources reliably—keeps failure modes visible and maintainable.

java exceptions: Practical Usage and Code Examples | RYUSLOG DEV