> ## 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.

# How to Read a Java Stack Trace, Line by Line, Fast
- URL: https://www.ggorantala.dev/reading-java-stack-traces/
- Published: 2026-10-06T01:12:00.000Z
- Updated: 2026-11-01T02:12:33.000Z
- Description: How to read a Java stack trace: what each line means, why the top frame can mislead you, and how to find the frame that actually matters in under a minute.
- Author: Gopi Gorantala
- Tags: stack-traces, debugging

I opened `BigDecimal.java` line 608 first. Everybody does. It's the top line of the `Caused by` block, it's a JDK class, it looks authoritative, and I spent a few minutes reading a constructor that had done nothing wrong. The useful information was sitting in the message of the *outer* exception, the very first line of the trace, and the useful frame was `Lab.java:13`, the last one in that block.

## The trace that sent me to the wrong file

Here's the one we're decoding. It's from a tiny invoice parser, run on OpenJDK 21.0.12.1 (Linux, plain `java -cp out Lab chain`, output copied as-is from the console):

```log
Exception in thread "main" Lab$InvoiceParseException: bad amount 'abc'
	at Lab.parseAmount(Lab.java:15)
	at Lab.total(Lab.java:21)
	at Lab.main(Lab.java:36)
Caused by: java.lang.NumberFormatException: Character a is neither a decimal digit number, decimal point, nor "e" notation exponential mark.
	at java.base/java.math.BigDecimal.<init>(BigDecimal.java:608)
	at java.base/java.math.BigDecimal.<init>(BigDecimal.java:497)
	at java.base/java.math.BigDecimal.<init>(BigDecimal.java:903)
	at Lab.parseAmount(Lab.java:13)
	... 2 more

```

Two exceptions, nine lines of frames, and one line that says `... 2 more`. If you've ever pasted something like this into Slack with "what am I looking at?", this article is for you. If you know how to read a Java stack trace already, skip to the part about `... 2 more`; it's the most misunderstood line in the language.

## If you only read one line

Read the first line, then read the *last* `Caused by`. That's it, that's the 20-second version.

The first line says what the code that finally gave up decided to tell you: an `InvoiceParseException`, with `'abc'` in the message. The last `Caused by` says what actually went wrong underneath: `BigDecimal` didn't like the letter `a`. The frames in between tell you the path. And the one frame I'd open in the IDE is `Lab.parseAmount(Lab.java:13)`, because that's where *our* code handed the bad string to the JDK.

Not line 608 of `BigDecimal`. Not line 15 either, which is only where we wrapped the failure.

## Build the trace on purpose

Pinned JDK for everything below: OpenJDK 21.0.12.1\. Nothing else is needed; no libraries.

Save this as `Lab.java`. Keep the blank lines. The line numbers in the traces depend on them, and I mean it, one extra newline and your `:36` becomes `:37`.

```java
import java.math.BigDecimal;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

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

    static BigDecimal parseAmount(String raw) {
        try {
            return new BigDecimal(raw.trim());              // line 13: JDK rejects the input
        } catch (NumberFormatException e) {
            throw new InvoiceParseException("bad amount '" + raw + "'", e);
        }
    }

    static BigDecimal total(List<String> lines) {
        BigDecimal sum = BigDecimal.ZERO;
        for (String l : lines) sum = sum.add(parseAmount(l));
        return sum;
    }

    static RuntimeException newProblem(String why) {
        return new IllegalStateException(why);              // line 26: created HERE
    }

    static void check(int qty) {
        if (qty < 0) throw newProblem("negative qty " + qty); // line 30: thrown HERE
    }

    public static void main(String[] args) throws Exception {
        String mode = args.length > 0 ? args[0] : "chain";
        switch (mode) {
            case "chain" -> total(List.of("10.50", "abc"));          // line 36
            case "created" -> check(-3);                              // line 37
            case "caller" -> List.of("a", "b").get(5);                // line 38
            case "pool" -> {
                ExecutorService ex = Executors.newFixedThreadPool(1);
                try { ex.submit(() -> total(List.of("x"))).get(); }   // line 41
                finally { ex.shutdown(); }
            }
            case "execute" -> {
                ExecutorService ex = Executors.newFixedThreadPool(1);
                ex.execute(() -> total(List.of("y")));                // line 46
                ex.shutdown();
            }
        }
    }
}

```

Then:

```bash
javac -d out Lab.java
java -cp out Lab chain

```

You should get the trace from the top of the page. If your console prints a `Picked up JAVA_TOOL_OPTIONS: ...` line first, that's your environment talking, not the program; it goes to stderr and has nothing to do with the exception.

One warning, because it cost me an hour. Don't run this as `java Lab.java` (the single-file source launcher). I did, at first, and the trace had a different shape: the outer exception showed only three frames while the `Caused by` block listed the launcher's own reflection frames (`jdk.compiler/com.sun.tools.javac.launcher.Main.execute`...) and no `... N more` at all. The launcher trims the top-level exception's frames but not the cause's. That's a launcher quirk, not how Java prints traces, and it'd have made the `... 2 more` lesson look wrong. Compile it. Run it from classes.

Now vary it. Each of these is a one-word change:

```bash
java -cp out Lab created     # exception built in one method, thrown in another
java -cp out Lab caller      # JDK code throws, your code was the caller
java -cp out Lab pool        # task fails inside a thread pool, result read with get()
java -cp out Lab execute     # same task, but nobody reads a result

```

If your `pool` run shows `ForkJoinPool` frames, you've swapped in `supplyAsync` without an executor. Same lesson, different pool.

## How to read a Java stack trace, line by line

The same trace, with markers:

```log
Exception in thread "main" Lab$InvoiceParseException: bad amount 'abc'    (1)
	at Lab.parseAmount(Lab.java:15)                                       (2)
	at Lab.total(Lab.java:21)                                             (3)
	at Lab.main(Lab.java:36)                                              (4)
Caused by: java.lang.NumberFormatException: Character a is neither ...    (5)
	at java.base/java.math.BigDecimal.<init>(BigDecimal.java:608)         (6)
	at java.base/java.math.BigDecimal.<init>(BigDecimal.java:497)
	at java.base/java.math.BigDecimal.<init>(BigDecimal.java:903)
	at Lab.parseAmount(Lab.java:13)                                       (7)
	... 2 more                                                            (8)

```

**(1)** Thread name, then the exception's `toString()`: class name, a colon, the message. `Lab$InvoiceParseException` has a dollar sign because it's a nested class and this is its binary name. The thread name matters more than you'd think. `"main"` tells you this was the main thread; in a pool it'd say `"pool-1-thread-1"`, and that's the first hint you're in async territory.

**(2)** to **(4)** are frames, newest first. The top frame is the method that was running when the `Throwable` was *constructed*. Each line below is the caller of the one above it. Read downward and you're walking backwards in time, out toward `main`.

A frame looks like `Lab.parseAmount(Lab.java:15)`: class, method, then the source file and the line *currently executing in that method*. For callers, that line is the call site. So `Lab.total(Lab.java:21)` means "`total` was sitting on line 21, in the middle of calling `parseAmount`".

**(5)** `Caused by:` starts a second, complete exception that the first one wraps. We wrote that wrapping ourselves on line 15: `new InvoiceParseException(..., e)`. Chaining happens only because someone passed `e` as the cause. If they hadn't, this block wouldn't exist, and you'd have the outer message and nothing else.

**(6)** In `java.base/java.math.BigDecimal.<init>`, `java.base` is the module name. Our own `Lab` frames have no prefix, because they come from the unnamed module on the class path, loaded by the built-in application class loader. Anything the JVM considers a JDK module drops the version number; a library jar on the module path with a named module would print `name@version/`. `<init>` is a constructor. There are three of them because one constructor hands off to another, and that one to a third; the trace shows each hop.

**(7)** `Lab.parseAmount(Lab.java:13)`. This is the line that matters. Line 13 is where our code called into the JDK with a string it should have checked. Line 15 (frame 2) is a different line in the same method, because the method was executing a different statement when the *wrapper* was built.

**(8)** `... 2 more`. Now the interesting bit.

## Where these lines come from, and what changed after JDK 8

`... 2 more` doesn't mean frames got lost. It means two frames at the *bottom* of this block are identical to the two frames at the bottom of the enclosing trace, so the printer doesn't repeat them. Count up from the bottom of the outer trace: `Lab.main(Lab.java:36)` and `Lab.total(Lab.java:21)` are both also the bottom of the cause's trace. Those are the two. If you wanted the cause's full stack, you add them back: four printed frames plus two elided makes six.

Notice what *didn't* match, though. `Lab.parseAmount` appears in both, at line 13 in one and line 15 in the other. Different line numbers, so not identical frames, so it's printed. That's the rule, and I only believed it after trying it: the comparison runs from the end of both stack traces, frame by frame, and it stops at the first mismatch, line numbers included.

The Javadoc for `Throwable.printStackTrace` says the same in softer words: the marker means the remaining frames match the bottom frames of the enclosing trace. I wanted to read the printing code itself, and couldn't this run; the sandbox this was written in couldn't reach the OpenJDK repository and the local `src.zip` was a dead link. So everything in this section about elision comes from the Javadoc plus the experiments above, not from reading `Throwable.java`. I'd rather say that than pretend. When you want to check the code, `printEnclosedStackTrace` is the method to find.

Getting here took me longer than reading this will take you: Javadoc first, then breaking the lab on purpose until each claim held up. That's the only reason I'm comfortable writing down the next bit.

The top frame isn't where the exception was thrown. It's where the `Throwable` was *created*. The stack is captured in the constructor, via `fillInStackTrace`, which the Javadoc confirms ("the constructors call this method automatically"). The `created` mode shows it:

```log
Exception in thread "main" java.lang.IllegalStateException: negative qty -3
	at Lab.newProblem(Lab.java:26)
	at Lab.check(Lab.java:30)
	at Lab.main(Lab.java:37)

```

Line 26 builds the exception inside a little factory. Line 30 throws it. The trace shows the factory on top, and happens to show the throw site too, one frame down, because `check` called the factory. If the exception had been stored in a field and thrown later from somewhere else, you'd never see the throw site at all.

On versions. What I ran: JDK 21 only. The frame format with a `java.base/` prefix comes from JDK 9's module system; on JDK 8 that same line reads `at java.math.BigDecimal.<init>(BigDecimal.java:608)`, and I did not run 8, so treat that as the documented behaviour rather than something I watched. The `StackTraceElement` Javadoc describes the full form (`classloader/module@version/class.method(file:line)`), says the class loader part is dropped for built-in loaders, and says the module part is dropped for the unnamed module. My output matches both rules. Newer JDKs also put a detailed message on `NullPointerException`, which changes the first line of those traces; the [NPE article](https://ggorantala.dev/java-nullpointerexception-cannot-invoke-because-null/?ref=ggorantala.dev) owns that.

Here's the shape in a picture, because the order trips people up:

```log
call order (time ->)            printed order (top = newest)

main()                          parseAmount()   <- exception created here
  total()                       total()
    parseAmount()               main()
      new BigDecimal(...)
        (throws)

```

## The scan-down heuristic, and where it breaks

The advice you've heard: scan down until you hit a class from your own package, and open that. It's right often enough to be dangerous.

It fails when your code *passed bad input* to something that rejected it. Look at the `chain` trace again. The first frame in our package is `parseAmount:15`, which is a `throw` statement we wrote ourselves. Opening it tells you nothing about *why*. The bad value, `'abc'`, is only in the message. And where did `'abc'` come from? Not from any frame. It came from whoever built the list in `main`, which is *data*, and no stack trace carries data. The frame is the victim here. The culprit is upstream of the call.

The second misreading is the one I fell for: opening the top frame of the *innermost* `Caused by`. That frame is inside the JDK. A `NumberFormatException` thrown from a `BigDecimal` constructor is behaving correctly. The first frame that's yours is `parseAmount:13`, the call into it.

Third, the myth that `... 2 more` means "two frames got dropped". It's the opposite: those frames are shown, in the block above, and the printer is saving you from reading them twice.

A fourth, smaller one. In the `caller` run, `List.of("a","b").get(5)` throws from `ImmutableCollections$AbstractImmutableList.outOfBounds` at the top. Here the heuristic works, since `Lab.main(Lab.java:38)` is the very next frame and it *is* the culprit. Same top-frame-is-the-JDK shape as the `BigDecimal` case, opposite answer. What separates them is whether the exception was handed back to you through a wrapper. When there's a `Caused by`, read it first. When there isn't, the first frame of yours is usually the one.

## Making it readable next time

Chain your causes: always pass `e` into the new exception, the way line 15 does. Put the offending value in the message too, because the frames can't tell you `'abc'`.

Here's what the failure looks like if you break the first one. Change line 15 to `throw new InvoiceParseException("bad amount", null);` and the whole `Caused by` block disappears. You'd be left with `parseAmount:15` and no idea what went wrong underneath. And what does it cost? Nothing at runtime to speak of; a longer log line, and care that the message doesn't leak anything you shouldn't log (account numbers, tokens). I'll write that up properly in [Writing Java Exceptions That Produce Useful Stack Traces](https://ggorantala.dev/java-custom-exceptions-stack-traces/?ref=ggorantala.dev).

Debug info is the other lever. Compile the same lab three more ways and run `created` each time:

```log
javac -d o Lab.java                 ->  at Lab.newProblem(Lab.java:26)
javac -g:lines,source -d o Lab.java ->  at Lab.newProblem(Lab.java:26)
javac -g:source -d o Lab.java       ->  at Lab.newProblem(Lab.java)
javac -g:none -d o Lab.java         ->  at Lab.newProblem(Unknown Source)

```

Default `javac` gives you lines and source. And here's the one that surprised me: `-g:lines` *alone* prints `Unknown Source`, even though the line numbers are in the class file. With no source file name recorded, the frame has nothing to show. That's `Unknown Source` and what causes it, covered properly in [Unknown Source and Missing Line Numbers](https://ggorantala.dev/java-stack-trace-unknown-source/?ref=ggorantala.dev).

## Making traces survive your logs

Two things kill traces in production, and neither is the JVM.

First, you log the message and drop the throwable. `log.error("failed: " + e.getMessage())` keeps `bad amount 'abc'` and loses every frame. Pass the exception as the last argument to your logger instead. [Logging Java Exceptions Properly](https://ggorantala.dev/logging-java-exceptions-correctly/?ref=ggorantala.dev) goes through the details, including how log shippers split one trace into thirty events.

Second, the thread. Run `pool` and `execute` and compare. In `pool`, the exception is rethrown *to the caller* inside an `ExecutionException`, and the real trace sits in the `Caused by`, whose bottom frames end in `ThreadPoolExecutor$Worker.run` and `Thread.run(Thread.java:1583)`, not `main`. With `execute`, nobody reads a result, so the thread's default handler prints it as `Exception in thread "pool-1-thread-1"` with the same frames and no `main` anywhere. Also, the lambda in `execute` is called `lambda$main$1`, not `$0`; the compiler numbers them in order, and I'll come back to that in the [odd frame names article](https://ggorantala.dev/java-stack-trace-strange-frames/?ref=ggorantala.dev).

JDK versions. The `Thread.java:1583` and `ThreadPoolExecutor.java:1144` numbers are specific to 21.0.12.1; yours may differ by a few lines on another build. The JVM also caps how many frames it keeps. `-XX:+PrintFlagsFinal` on this JDK shows `MaxJavaStackTraceDepth = 1024`, and a 3,000-deep recursion kept exactly 1,024 frames when I counted. That's where the odd "my trace ends nowhere" report comes from, and it's the subject of [Where Did My Stack Trace Go?](https://ggorantala.dev/java-missing-stack-trace/?ref=ggorantala.dev).

## The short list

- The top frame is where the exception was created, not necessarily where it was thrown.
- Read the first line, then the last `Caused by`. Then find the first frame of yours in *that* block.
- `... N more` means N frames are shared with the enclosing trace, counted from the bottom, until the first mismatch. Nothing was lost.
- A frame that's yours isn't always the guilty one; bad data came from somewhere the trace doesn't show.
- `main` at the bottom is only true for the main thread. In a pool you'll see `Thread.run`.
- `Unknown Source` means no source file name was compiled in, even when line numbers are.

## Questions people actually search

**What does "... 23 more" mean in a Java stack trace?**  
Those 23 frames are identical to the bottom 23 frames of the trace above it, so the printer skips them. Count from the bottom of both traces until they differ.

**Which line of a Java stack trace should I read first?**  
The first line for the exception and message, then the last `Caused by:` for the underlying problem. Then look for the first frame in your own code inside that block. See [Caused by in Java Stack Traces](https://ggorantala.dev/java-caused-by-root-cause/?ref=ggorantala.dev) for the full chain walk.

**Is the top frame of a stack trace where the exception was thrown?**  
Not always. It's where the `Throwable` was constructed. In my `created` run the factory method `newProblem:26` is on top and the `throw` is on line 30, one frame lower. Usually the two are the same place.

**Why does my stack trace say "Unknown Source"?**  
The class was compiled without the source file name, for instance with `-g:none`, or `-g:lines` on its own. Recompile with the default `javac` options or `-g:lines,source`.