> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Caused by in Java Stack Traces: Finding the Root Cause
- URL: https://www.ggorantala.dev/java-caused-by-root-cause/
- Published: 2026-09-30T00:58:00.000Z
- Updated: 2026-11-01T02:02:46.000Z
- Description: A Caused by in a Java stack trace is a wrapped second exception. How chains print, what '... N more' hides, and how to find the root cause every time.
- Author: Gopi Gorantala
- Tags: stack-traces, java-exceptions

`throw new RuntimeException(e.getMessage());`

That one line is the reason a `Caused by` goes missing, and I've seen it in more codebases than I'd like to admit. The message survives. Every frame underneath it, the part that says *where* things went wrong, is gone. We'll break it on purpose below, but first the healthy version of the problem: a trace with three exceptions stacked in it.

## Three exceptions, one root cause, and `... 2 more`

This is from a small config loader, OpenJDK 21.0.12.1 on Linux, compiled with plain `javac` and run as `java -cp out Lab chain`. Copied from the console as-is:

```log
Exception in thread "main" java.lang.IllegalStateException: Lab$ConfigException: cannot load /etc/app/app.properties
	at Lab.loadWrapped(Lab.java:40)
	at Lab.main(Lab.java:56)
Caused by: Lab$ConfigException: cannot load /etc/app/app.properties
	at Lab.load(Lab.java:24)
	at Lab.loadWrapped(Lab.java:38)
	... 1 more
Caused by: java.io.IOException: Permission denied
	at Lab.readFile(Lab.java:14)
	at Lab.load(Lab.java:19)
	... 2 more

```

Every `Caused by` block is a complete exception with its own stack, hanging off the one above it. This is the `caused by java stack trace` shape you'll meet in every real service: an exception at the top that nobody wanted to see, and the one that matters at the bottom. The full reading routine for any trace lives in [How to Read a Java Stack Trace](https://ggorantala.dev/reading-java-stack-traces/?ref=ggorantala.dev). This article is about one thing in it: the chain.

## The two-second version

The last `Caused by` is the root cause. Here it's `IOException: Permission denied`, thrown at `Lab.readFile(Lab.java:14)`. Everything above it is somebody catching that and wrapping it in something more fitting for their layer.

My first instinct, and I suspect yours, was to open `readFile:14` and stop. Wrong half the time. Look at what that message *doesn't* say: which file. `Permission denied`, and nothing else. The path is in the wrapper's message, `cannot load /etc/app/app.properties`, two exceptions up. So the answer is: the bottom tells you what, the top tells you what it was doing at the time, and you need both lines.

## Reproduce it

Pinned JDK: OpenJDK 21.0.12.1\. No libraries. Save the file below as `Lab.java`, and keep the blank lines, because every line number in this article depends on them.

```java
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
import java.io.StringReader;

public class Lab {
    static class ConfigException extends RuntimeException {
        ConfigException(String msg, Throwable cause) { super(msg, cause); }
        ConfigException(String msg) { super(msg); }
    }

    static String readFile(String path) throws IOException {
        throw new IOException("Permission denied");                  // the real problem
    }

    static Properties load(String path) {
        try {
            String text = readFile(path);                           // line 19: where readFile is called
            Properties p = new Properties();
            p.load(new StringReader(text));
            return p;
        } catch (IOException e) {
            throw new ConfigException("cannot load " + path, e);    // line 24: wrap, keep the cause
        }
    }

    static Properties loadLossy(String path) {
        try {
            return load(path);                                      // line 30
        } catch (ConfigException e) {
            throw new RuntimeException(e.getMessage());             // line 32: message copied, cause dropped
        }
    }

    static Properties loadWrapped(String path) {
        try {
            return load(path);                                      // line 38
        } catch (ConfigException e) {
            throw new IllegalStateException(e);                     // line 40: Throwable(Throwable) constructor
        }
    }

    static Throwable root(Throwable t) {
        List<Throwable> seen = new ArrayList<>();
        while (t.getCause() != null && !seen.contains(t.getCause())) {
            seen.add(t);
            t = t.getCause();
        }
        return t;
    }

    public static void main(String[] args) throws Exception {
        String mode = args.length > 0 ? args[0] : "chain";
        switch (mode) {
            case "chain" -> loadWrapped("/etc/app/app.properties");        // line 56
            case "lossy" -> loadLossy("/etc/app/app.properties");          // line 57
            case "root" -> {
                try { loadWrapped("/etc/app/app.properties"); }
                catch (Exception e) { System.out.println("root = " + root(e)); }
            }
            case "cycle" -> {
                Exception a = new Exception("a");
                Exception b = new Exception("b", a);
                a.initCause(b);                                            // line 65
                a.printStackTrace();
                System.out.println("root(a) = " + root(a));
            }
            case "initcause" -> {
                Exception x = new Exception("x", null);
                x.initCause(new Exception("y"));                           // line 71: cause already set (to null)
            }
            case "thread" -> {
                Thread t = new Thread(() -> { throw new ConfigException("in thread", new IOException("disk")); }, "loader-1");
                t.start();
            }
        }
    }
}

```

Compile it, then run the modes one at a time:

```bash
javac -d out Lab.java
java -cp out Lab chain      # the trace at the top of the page
java -cp out Lab lossy      # cause dropped
java -cp out Lab root       # getCause() loop
java -cp out Lab cycle      # two exceptions that blame each other
java -cp out Lab initcause  # overwriting a cause
java -cp out Lab thread     # cause with no frames of its own

```

Compile with `javac`; don't use the `java Lab.java` source launcher. I wasted time on that in the [first article](https://ggorantala.dev/reading-java-stack-traces/?ref=ggorantala.dev) already: the launcher trims the frames of the outer exception but not the cause, so `... N more` lines come out different from what a normal run prints. If your console shows a `Picked up JAVA_TOOL_OPTIONS` line first, that's stderr from your environment, nothing to do with the exception.

Here's `lossy`:

```log
Exception in thread "main" java.lang.RuntimeException: cannot load /etc/app/app.properties
	at Lab.loadLossy(Lab.java:32)
	at Lab.main(Lab.java:57)

```

Message intact. Where it came from: gone. The permission problem isn't anywhere in this output, and nothing you do to the log configuration will bring it back, because the information never left the JVM's heap.

## Every line, annotated

```log
Exception in thread "main" java.lang.IllegalStateException: Lab$ConfigException: cannot load ...   (1)
	at Lab.loadWrapped(Lab.java:40)                                       (2)
	at Lab.main(Lab.java:56)
Caused by: Lab$ConfigException: cannot load /etc/app/app.properties       (3)
	at Lab.load(Lab.java:24)                                              (4)
	at Lab.loadWrapped(Lab.java:38)                                       (5)
	... 1 more                                                            (6)
Caused by: java.io.IOException: Permission denied                         (7)
	at Lab.readFile(Lab.java:14)                                          (8)
	at Lab.load(Lab.java:19)                                              (9)
	... 2 more                                                            (10)

```

**(1)** The outermost exception, with a message that looks like it repeats itself. It does. Line 40 used `new IllegalStateException(e)`, the constructor that takes only a cause, and that constructor builds its message from `cause.toString()`: class name, colon, message. So the whole `Lab$ConfigException: cannot load ...` text is pasted into the first line. Not a bug; a habit worth knowing, since it's why so many log lines start with an exception name inside an exception name.

**(2)** Frames for this one exception only, newest first. Line 40 is where the `IllegalStateException` was constructed.

**(3)** `Caused by:` introduces the exception that `getCause()` returns on the one above. Same format as a top-level trace, minus the thread name.

**(4)** and **(5)** Two frames. `Lab.load` at line 24 is where the `ConfigException` was built. `Lab.loadWrapped` at line 38 is where `load` was called from.

**(6)** `... 1 more`. One frame at the bottom of this block, `Lab.main(Lab.java:56)`, is identical to the bottom of the trace above it, so the printer skips it. It's one frame, and it's the one printed on line 3 of the output.

**(7)** The root cause. The bottom of the chain. No further `Caused by` follows, which means `getCause()` returned `null`.

**(8)** and **(9)** The first frame is where `new IOException(...)` ran. The second is the call that led there, at `Lab.load(Lab.java:19)`. Note it's line 19 here and line 24 in frame (4): the same method, caught at two different statements, and that matters in a second.

**(10)** `... 2 more`. Two frames hidden this time. Which two? Count from the bottom of both stacks. The IOException's full stack is four frames: `readFile:14`, `load:19`, `loadWrapped:38`, `main:56`. I confirmed that by printing `getStackTrace()` for each exception in the chain. The `ConfigException`'s is `load:24`, `loadWrapped:38`, `main:56`. Starting from the bottom, `main:56` matches, `loadWrapped:38` matches, and `load:19` against `load:24` does not. Two in common. Stop.

Which line would I open first? `Lab.load(Lab.java:24)`, because that's where our own code decided what to say about the failure. Then `readFile:14` for the cause itself.

## Inside printStackTrace: how the chain gets printed

None of this is stored in the exception as text. The stack is an array of frames captured at construction. The `Caused by` structure is nothing more than a field called `cause`, and the printer walks it at print time.

I read the printing code (OpenJDK `Throwable.java` at `jdk-21+35`) through a summarising fetch tool, not line by line in an editor, so take the details below as what that read showed and the experiments confirmed. The private method that prints a chained exception takes a *caption* (`"Caused by: "` or `"Suppressed: "`), an indent prefix, and the stack trace of the exception that holds this one. It walks both arrays from the end while frames compare equal, and the number it ends up with is the `N` in `... N more`. Frames before the first mismatch are printed; the shared ones are counted and replaced by that one line.

Two consequences I didn't expect.

First, "equal" includes the line number. A `StackTraceElement` is the same only if class, method, file *and line* match. So a wrapper built on a different line of the same method than the call that threw (very common) ends the shared stretch early. That's what happened to `load:19` versus `load:24`.

Second, the printer keeps an identity-based set of every throwable it has already printed. If a cause chain loops back, it doesn't recurse forever. It prints a marker instead. Run `cycle`:

```log
java.lang.Exception: a
	at Lab.main(Lab.java:63)
Caused by: java.lang.Exception: b
	at Lab.main(Lab.java:64)
Caused by: [CIRCULAR REFERENCE: java.lang.Exception: a]
root(a) = java.lang.Exception: b

```

Exception `b` was built with `a` as its cause, then line 65 told `a` that its cause was `b`. Nothing stops you; the cause of `a` hadn't been set yet. This is rare in the wild, and I only built it to see what the JVM does. The printer survives. A naive `while (e.getCause() != null) e = e.getCause();` would not, which is why my `root()` helper remembers what it has seen. Its answer here (`b`) is arbitrary; a cycle has no root.

One more oddity. `getCause()` doesn't return the raw field. A new `Throwable` starts with `cause` pointing at itself, as a "never set" marker, and `getCause()` turns that into `null`. That's why this:

```log
Exception in thread "main" java.lang.IllegalStateException: Can't overwrite cause with java.lang.Exception: y
	at java.base/java.lang.Throwable.initCause(Throwable.java:464)
	at Lab.main(Lab.java:71)
Caused by: java.lang.Exception: x
	at Lab.main(Lab.java:70)

```

happens in the `initcause` mode. `new Exception("x", null)` is *explicitly* "no cause", which counts as set, so a later `initCause` is refused. The `Caused by: x` is the JDK attaching the original exception to the complaint. `initCause` exists for exceptions whose constructors can't take a cause; if you control the class, use the constructor. (`Throwable.java:464` is specific to 21.0.12.1.)

The print format is old. The `Caused by` and `... N more` style dates back to JDK 1.4, and as far as I know it's unchanged. I couldn't run JDK 8, 17 or 25 here; this lab ran on 21 only. There's even a long-open request, [JDK-6507809](https://bugs.openjdk.org/browse/JDK-6507809?ref=ggorantala.dev), arguing the whole format is hard to read and should be redone. It hasn't been.

It took me two sittings to get to the bottom of this one, most of it on the `thread` case further down. You get to skip that.

## The Caused by with no frames, and other near-misses

Run `thread`:

```log
Exception in thread "loader-1" Lab$ConfigException: in thread
	at Lab.lambda$main$0(Lab.java:74)
	at java.base/java.lang.Thread.run(Thread.java:1583)
Caused by: java.io.IOException: disk
	... 2 more

```

A `Caused by` with no frames at all, only `... 2 more`. My first guess was a lost trace. I went looking for a flag. Nothing was lost. Both exceptions were built on line 74, in the same lambda, in the same call stack, so every frame of the cause matches the bottom of the outer trace and the printer folds all of them. The cause is *entirely* shared. If you see this, the wrapper and the thing it wraps were created at the same spot, usually `new X("...", new Y("..."))` on one line.

The one that actually costs people time is the wrong "root". Three more:

**Blaming the last `at` of the top block.** That's the call stack of the wrapper, not the failure. The failure is in the last block.

**Assuming the bottom exception is always the useful one.** Our `IOException` had no path. If the bottom had been `NullPointerException` with no message, the outer exception's text would be the only business context you'd get. Read both.

**Believing `... 2 more` means two frames are missing.** They're printed, once, in the block above. It's the same myth as in the hub article, and I'll keep repeating it until it dies.

And a fourth, from the wrapper that eats its cause. In `lossy`, someone wrote `new RuntimeException(e.getMessage())`. There's no `Caused by` block, and no way to get one back. If you inherit a codebase with that pattern, grep for `getMessage())` inside `throw new` first.

## Write it so it explains itself

Pass the cause. Every time. `new ConfigException("cannot load " + path, e)` on line 24 costs nothing and it's the only reason the chain printed at all. The single-argument `new IllegalStateException(e)` on line 40 works too, but gives you that doubled message. I prefer to pass a message *and* the cause so the first line says what the code was doing.

Put the identifying value in the wrapper's message, since the root cause often doesn't have it. Costs: a longer log line, and care not to put secrets (tokens, account numbers) in the text. I'll come back to that in [Writing Java Exceptions That Produce Useful Stack Traces](https://ggorantala.dev/java-custom-exceptions-stack-traces/?ref=ggorantala.dev).

Finding the root programmatically? My `root()` loop above is fine for a log-enrichment helper, with the cycle guard. Libraries ship the same thing (Apache Commons Lang's [ExceptionUtils.getRootCause](https://commons.apache.org/proper/commons-lang/apidocs/org/apache/commons/lang3/exception/ExceptionUtils.html?ref=ggorantala.dev) is the usual one; I linked it but didn't read its source). Don't build alerting logic that *only* looks at the root class, though. A `SQLException` root can be a deadlock or a typo in a column name, and the wrapper above it is the thing that knows which operation was running.

What isn't a fix: logging at every layer. Each `catch` that logs and rethrows produces its own copy of the chain, and now the same `IOException` is in your logs four times with slightly different `... N more` counts. Log once, at the boundary.

## Keeping the chain alive in your logs

The chain survives only if the logger receives the `Throwable` itself. `log.error("load failed: " + e)` keeps the exception's `toString()` and drops `Caused by`, the same way the `lossy` mode did. Pass the exception as the last argument and the logging framework prints the chain. [Logging Java Exceptions Properly](https://ggorantala.dev/logging-java-exceptions-correctly/?ref=ggorantala.dev) covers that and what happens when a log shipper splits it across thirty events.

Logback and Log4j2 both have pattern options to shorten the chain or print the root cause first. I didn't run either for this article, so check the docs for the version you use; don't copy a pattern from a blog post (including this one) without testing what it does to your `... N more` lines. Some of them reorder frames in a way that makes the elision harder to follow.

Which JDKs print what, from what I could verify: the chain format above is from JDK 21.0.12.1 and I ran nothing else. `Caused by`, `... N more` and the circular-reference marker are, as far as I know, stable on every recent LTS, but I haven't watched them on 8, 17 or 25, so I'm not putting that in writing as fact. The module prefix (`java.base/`) in `Throwable.initCause` is a JDK 9+ thing and will be absent on 8.

## If you remember nothing else

- The last `Caused by` is the root cause, and the outer message is often where your business context lives. Read both ends.
- `... N more` counts frames shared with the enclosing trace from the bottom up, stopping at the first mismatch, line numbers included. Nothing is lost.
- A `Caused by` with only `... N more` means wrapper and cause were created in the same place.
- `new X(e.getMessage())` throws the chain away. `new X(msg, e)` keeps it.
- Walking `getCause()` needs a cycle guard; `printStackTrace` has one, your loop doesn't.

## People also ask

**What does "Caused by" mean in a Java stack trace?**  
It starts the stack trace of the exception that `getCause()` returned for the one above. The code caught a low-level failure and threw a new exception that carries the first as its cause. Verified on JDK 21.

**How do I find the root cause in a long Java stack trace?**  
Scroll to the last `Caused by:` block. It's the exception with no cause of its own. Programmatically, loop on `getCause()` until it returns `null`, and remember values you've seen so a cycle can't hang you. See the loop in [the lab above](#reproduce-it).

**What does "... 23 more" mean in a stack trace?**  
Twenty-three frames at the bottom of this block are identical to the bottom 23 of the enclosing exception, so they aren't printed twice. Count up from the bottom of both until a frame differs; the line number counts. The basics are in [the hub article](https://ggorantala.dev/reading-java-stack-traces/?ref=ggorantala.dev).

**Why is my Caused by missing?**  
Someone threw a new exception without passing the old one in, for instance `new RuntimeException(e.getMessage())`. The cause was never stored, so no logger can print it.