When a low-level failure causes a higher-level failure, there's a choice about which exception the code throws to the caller. Throwing only the high-level one loses the original stack trace, which is the main clue about what actually went wrong. Throwing only the low-level one leaks implementation details that the caller never agreed to know about. Exception chaining is the middle path: throw a new exception that fits the layer's abstraction and attach the original as its cause. The original stack trace is preserved, the caller still sees the API it expects, and the printed trace shows the full layered story.
Most real code is layered. A CheckoutService calls an OrderParser, which reads a row of CSV data. Deep inside the parser, Double.parseDouble("twelve") throws NumberFormatException. That exception is correct in the sense that it accurately describes what went wrong, but the caller of CheckoutService doesn't care about the parsing library used inside the order pipeline. The caller cares that the order couldn't be processed.
Three options exist when the parser catches NumberFormatException.
| Option | Exception thrown | Stack trace contains | Caller sees |
|---|---|---|---|
| Let it propagate | The original NumberFormatException | Full trace down to parseDouble | A low-level exception leaking from a high-level API |
| Catch and rethrow new | A fresh OrderProcessingException with no cause | Trace ending at the rethrow, not the original | A clean type, but no way to find the real bug |
| Catch and chain | OrderProcessingException wrapping the NumberFormatException | Both traces, separated by Caused by: | A clean type, full diagnostic detail |
The third option is exception chaining. The new exception is what flows out of the method. The original is preserved as the cause field on the new exception, where logging and debugging tools can read it. The next several sections work through how to do it, how the trace looks in practice, and what to avoid.
The standard form for chaining is the two-argument constructor that every well-behaved exception type provides:
That constructor exists on Throwable, Exception, RuntimeException, and almost every exception in the standard library. A custom exception should add the same two-arg constructor delegating to super(message, cause). Without it, callers can't chain through the exception type.
The parser scenario, made concrete:
Two stack traces show up in the output. The top one is the InvalidPriceException that bubbled out of parsePrice and was never caught. The bottom one, introduced by Caused by:, is the original NumberFormatException that the parser caught and wrapped. The ... 1 more line at the end is the JVM saying "the rest of the inner frames are the same as the outer trace, so I'm not printing them again." This compression keeps long chains readable.
The first line of each block carries the exception type and the message. The frames below it are the call stack from the throw site upward. Reading top-down, the chain of methods that produced each exception is visible, with the highest-level exception first and the original cause last.
Building an exception captures the current stack trace, which walks every frame on the thread's call stack. For deeply nested chains in hot paths, that allocation is real. Don't chain exceptions in tight loops where the exception path is the common path; that's a sign the loop should branch on data, not exceptions.
Skipping the cause and throwing a fresh exception produces a trace missing the most useful clue:
The original NumberFormatException is gone. There is no Caused by: block. From this trace, it's clear that parsePrice threw, but the specific objection from Double.parseDouble is lost. If the input had been an empty string, a leading plus sign, or hexadecimal notation, the message from NumberFormatException would have identified which. Reproducing the bug now requires stepping through the parser locally.
Multiplied across the dozens of exceptions a real service catches every day, the cost adds up. The fix is one parameter on one constructor call. There's almost never a good reason to skip it.
Once a cause is attached, the receiver can read it back with getCause(). The method returns null when no cause was set, and the wrapped Throwable when one was. The chain can be walked by calling getCause() repeatedly until it returns null:
The while loop walks until getCause() returns null. For a single-level chain, the loop runs once. For deeper chains (a service calls a repository, which calls a storage layer, each wrapping the one below it), the loop walks each layer in order. The deepest exception, the one whose getCause() returns null, is the root cause, the thing that actually broke.
A small helper pattern for finding just the root cause without printing every layer:
The current.getCause() != current check guards against a self-referential cycle, which is rare but possible if buggy code calls initCause(this). With that guard in place, the loop terminates even on broken inputs.
Real systems often produce chains that are three or four layers deep. A storage layer throws IOException, the repository wraps it as OrderRepositoryException, the service wraps that as OrderProcessingException, and the top-level handler logs it. A slightly bigger example showing the full chain.
Three exceptions, three stack traces, separated by Caused by: lines. Reading top to bottom, the trace tells a story:
OrderProcessingException saying processing failed for ORD-9001. That exception was thrown from OrderService.processOrder.OrderRepositoryException, thrown from OrderRepository.findOrder. The repository layer is where the next step happened.IOException, thrown from OrderStorage.readOrder. The actual problem was that the disk read failed.Without chaining, only the top frame would be visible, leaving the rest as guesswork. With chaining, the diagnostic is right there in the log. The same information that would normally take half an hour of code reading shows up in one trace.
The diagram lines up with the stack trace. The bottom of the trace, the root cause, is at the top of the diagram: it happens first in real time. Each layer up catches the exception from below, attaches it as the cause of a new exception that fits the layer's vocabulary, and throws. The caller at the top sees a single exception of an expected type, but the printed trace exposes the entire chain.
A common bug in exception handling is catching one exception and throwing another with no cause attached, and not logging the original. The stack trace vanishes.
What's wrong with this code?
The catch block receives e, the NumberFormatException that describes what went wrong, and then discards it. The new InvalidPriceException has a hardcoded message and no cause. When this method is called with "twelve" and again later with "", both produce identical output:
The specific rejection from Double.parseDouble is no longer visible. Whether the input was empty, malformed, out of range, or null is unclear. The trace doesn't include the offending value. The original exception object, the only thing that knew, was caught and silently dropped.
Fix: include both the offending value in the message and the original exception as the cause.
Now the trace shows the bad value in the top-level message and the JDK's own NumberFormatException message in the Caused by: block. Anyone reading the log can reproduce the bug from the information in the trace alone.
A rule of thumb: any catch block that throws a new exception should pass the caught exception as the second constructor argument. If the custom exception doesn't have a constructor that accepts a Throwable, add one. If the platform exception being caught is the right type to propagate as-is, don't catch it.
The two-argument constructor is the standard, but not every exception type has one. Some legacy code, some third-party libraries, and a handful of older JDK exceptions only have a (String) or no-arg constructor. For those cases, Throwable.initCause(Throwable) attaches the cause after construction:
The trace looks identical to the two-arg constructor version, with both exceptions and the Caused by: separator. The difference is purely how the cause got attached.
Two rules govern initCause. First, it can be called only once on a given exception; the second call throws IllegalStateException. Second, it cannot be called on an exception that was constructed with a cause already (whether by the two-arg constructor or by a previous initCause). Both rules exist to prevent accidentally overwriting a cause that some earlier code already set.
The exception message is precise about what happened: the cause was already set, and the JVM refuses to overwrite it. Prefer the two-arg constructor when it exists; use initCause only when the exception type doesn't give you that option.
initCause makes the call site noisier (three lines instead of one) and risks the runtime error above. When the exception type is under control, add a (String, Throwable) constructor and skip initCause entirely.
Custom exceptions get their own lesson, but one design point matters specifically for chaining: every domain exception should expose a constructor that accepts a Throwable cause. Without it, callers can't wrap a lower-level exception without falling back to initCause.
A complete, chain-friendly custom exception:
Three constructors cover the three common cases: message only (when there's nothing to wrap), message plus cause (the standard chaining call), and cause only (when the cause's own message is sufficient). Each one just delegates to the matching RuntimeException constructor, which in turn delegates to Throwable. There's no logic to maintain.
Java's standard library follows the same pattern almost everywhere. IOException, SQLException, IllegalArgumentException, IllegalStateException, RuntimeException itself, and most others all expose the (String, Throwable) form. The handful that don't (a few very old ones) are the reason initCause exists. New code should follow the pattern and never force callers to use initCause.
A common reason to chain is to change the checked-ness of an exception at a layer boundary. Checked exceptions must be declared with throws or caught; unchecked exceptions don't. The repository in the earlier example does exactly this kind of translation: IOException is checked, but OrderRepositoryException extends RuntimeException and is therefore unchecked.
That conversion serves a real purpose. The storage layer signals "I might fail in I/O ways, and the caller must explicitly handle that." The service layer is one step removed; signaling I/O specifics to the service's own callers would force every method up the call chain to declare throws IOException. Wrapping in an unchecked OrderRepositoryException lets the failure propagate to a top-level handler without polluting every signature on the way up.
The reverse case is rarer but legitimate. A library that internally uses RuntimeException for control flow might catch and wrap as a checked IOException at its public API, on the theory that I/O failure is what the caller signed up to handle.
In both directions, the chained cause keeps the original information available. The receiver sees the type that fits the working layer, and the trace shows the type that fits the layer underneath.
Chaining shows up in conversations about another related feature: suppressed exceptions, introduced with try-with-resources in Java 7. The two look similar in printed output, but they answer different questions.
A cause answers "what triggered this exception?" The chain goes downward into the stack frames that produced it. A suppressed exception answers "what additional exception happened on the way out that shouldn't be lost, but shouldn't replace the main one either?" In try-with-resources, if the body throws and then close() also throws while cleaning up, the close() exception is attached as a suppressed sibling of the body's exception, not as its cause.
In the printed trace, suppressed exceptions show up under Suppressed: rather than Caused by:, and they appear inside the trace of the main exception, not after it. They are accessible programmatically via Throwable.getSuppressed(), which returns an array (typically empty). The takeaway for this lesson is that chaining and suppression are different relationships and both can coexist on the same exception.
When logging a chained exception, log the entire chain in one operation, not one layer at a time. The standard pattern is logger.error("Order processing failed", exception). Every major logging framework (java.util.logging, SLF4J, Log4j 2) accepts a Throwable as the final argument and walks the cause chain automatically when it formats the trace. The output matches what Throwable.printStackTrace() produces, with the same Caused by: blocks.
Avoid this pattern, which writes the chain twice and drops information:
The two log lines contain only the messages, not the stack frames. If getCause() is null (because some intermediate layer dropped it), the second line throws NullPointerException and the logging branch becomes a real outage. The right form is one call that passes the exception object:
The logger does the right thing for any depth of chain, including chains that grow when a new wrapping layer is added later. Trust the framework to print the full trace; the job is to hand it the exception, not to format it.
To pull everything together, a small order pipeline that combines parsing failures and storage failures. The parser wraps NumberFormatException as InvalidPriceException. The repository wraps IOException as OrderRepositoryException. The service catches anything from below and wraps as OrderProcessingException. The top-level handler catches the service exception, walks the chain to log the root cause, and decides what to tell the user.
Two lines tell the operator everything needed: the high-level summary fit for a user-facing message, and the root cause fit for a debugging ticket. Nothing in the middle is lost; if a logger were also given the exception, the full three-layer trace would appear in the log alongside these two lines. The point of chaining: every layer keeps its own vocabulary, and no layer loses information.
Swapping the rawPrice for "42.50" and letting the repository fail produces:
The pipeline didn't change. The chain did, because the actual failure was different. The structure of the exception chain follows the structure of the real failure, and the handler reads it without having to know in advance which layer broke.
10 quizzes