Skip to content

How to Read a Java Stack Trace, Line by Line, Fast

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.

stack-traces debugging
Gopi Gorantala
Reading Progress

On This Page

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):

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.

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:

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:

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:

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:

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 owns that.

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

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.

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

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.

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

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

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.

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

stack-tracesdebugging

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments