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

# Unknown Source Stack Trace in Java: Fix Missing Line Numbers
- URL: https://www.ggorantala.dev/java-stack-trace-unknown-source/
- Published: 2026-11-01T02:08:30.000Z
- Updated: 2026-11-01T02:08:30.000Z
- Description: Unknown Source in a Java stack trace means the class has no SourceFile or LineNumberTable. How -g flags decide it, how to check with javap, and how to fix it.
- Author: Gopi Gorantala
- Tags: stack-traces, debugging

`Unknown Source` is almost never a JVM problem. It's a build setting, and you can read the answer straight off the class file in about ten seconds.

## The trace with no line numbers in it

OpenJDK 21.0.12.1, Linux, a webhook handler that blows up on an event with no body. Plain `java -cp out Lab` from the console, copied as-is:

```log
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.trim()" because the return value of "Lab$Event.body()" is null
	at Lab.signature(Unknown Source)
	at Lab.route(Unknown Source)
	at Lab.handle(Unknown Source)
	at Lab.main(Unknown Source)

```

Four frames, four `Unknown Source`. The message is perfectly good (an event's `body()` returned null), but you can't tell *which line* in `signature` did it, and in a real service with a 600-line class that's the difference between a one-minute fix and an afternoon. If you've searched the phrase "unknown source stack trace" you've landed here for the same reason I did.

Here's the same program compiled normally:

```log
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.trim()" because the return value of "Lab$Event.body()" is null
	at Lab.signature(Lab.java:9)
	at Lab.route(Lab.java:14)
	at Lab.handle(Lab.java:20)
	at Lab.main(Lab.java:26)

```

Same exception. Same frames. Only the parenthesised part changed, and that part isn't computed at runtime from your source. It's copied out of the class file.

## In one paragraph

Each frame prints `(File.java:line)`. The file name comes from a class-level attribute called `SourceFile`; the line number comes from a per-method attribute called `LineNumberTable`. If the class doesn't carry a `SourceFile`, the JVM prints `Unknown Source` and nothing else, whether or not it has line numbers. If it carries `SourceFile` but no `LineNumberTable`, you get `(Lab.java)` with no number. If the frame is a native method, you get `(Native Method)`. All three are about what's in the bytes of the class, so the fix is always on the build side, never in the JVM flags you start with.

I didn't believe the "whether or not it has line numbers" part when I first wrote it down. More on that below.

## Run it and compare

Pinned JDK: OpenJDK 21.0.12.1\. No libraries. Compile with `javac` and run the class files; I didn't use the `java Lab.java` source launcher for this one, so I can't tell you what it does with debug info. Keep the blank lines in the file; the line numbers in this article depend on them.

```java
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Path;

public class Lab {
    record Event(String id, String body) {}

    static String signature(Event e) {
        return e.body().trim();                                     // line 9: NPE when body is null
    }

    static String route(Event e) {
        return String.valueOf(
            signature(e)
                .toLowerCase()
                .length());                                         // line 16
    }

    static void handle(Event e) {
        route(e);                                                   // line 20
    }

    public static void main(String[] args) throws Exception {
        String mode = args.length > 0 ? args[0] : "plain";
        switch (mode) {
            case "plain" -> handle(new Event("evt_1", null));       // line 26
            case "wait" -> {
                Object lock = new Object();
                Thread t = new Thread(() -> {
                    synchronized (lock) {
                        try { lock.wait(); } catch (InterruptedException ie) { }   // line 31
                    }
                }, "hook-worker");
                t.start();
                Thread.sleep(300);
                for (StackTraceElement f : t.getStackTrace()) System.out.println("\tat " + f);
                System.exit(0);
            }
            case "loader" -> {
                URL jar = Path.of(args[1]).toUri().toURL();
                try (URLClassLoader cl = new URLClassLoader("plugin", new URL[]{jar}, Lab.class.getClassLoader())) {
                    cl.loadClass("Plug").getMethod("run").invoke(null);
                } catch (java.lang.reflect.InvocationTargetException ite) {
                    ite.getCause().printStackTrace();
                }
            }
        }
    }
}

```

`Plug.java`, used later for the jar experiment:

```java
public class Plug {
    public static void run() {
        throw new IllegalStateException("plugin failed");           // line 3
    }
}

```

Now compile the lab six ways and run `plain` each time. I used a separate output directory per variant, and (learned this one the slow way) don't put a colon in the directory name, because `-cp out-g:none` is two classpath entries.

```bash
javac           -d out_default Lab.java Plug.java
javac -g        -d out_g       Lab.java Plug.java
javac -g:none   -d out_none    Lab.java Plug.java
javac -g:lines  -d out_lines   Lab.java Plug.java
javac -g:source -d out_source  Lab.java Plug.java
javac -g:vars   -d out_vars    Lab.java Plug.java

for d in out_default out_g out_none out_lines out_source out_vars; do
  echo "== $d"; java -cp $d Lab plain 2>&1 | sed -n 2p
done

```

Second line of each trace, which is enough to see the frame format:

```log
== out_default
	at Lab.signature(Lab.java:9)
== out_g
	at Lab.signature(Lab.java:9)
== out_none
	at Lab.signature(Unknown Source)
== out_lines
	at Lab.signature(Unknown Source)
== out_source
	at Lab.signature(Lab.java)
== out_vars
	at Lab.signature(Unknown Source)

```

The row that surprised me is `out_lines`. I asked for line numbers, got them into the class file, and the trace still says `Unknown Source`. Hold that thought until the decoding section.

**Variations.** Plain `javac` with no `-g` at all behaves like `-g:source,lines`: the same trace as `-g`. That matches what the javac documentation says, and what a page-1 explainer on [Baeldung](https://www.baeldung.com/java-unknown-source-stack-trace?ref=ggorantala.dev) states. `-g` adds local variable tables on top, which turns out to matter for something else (see the helpful-NPE bit).

**If your trace differs.** A `Picked up JAVA_TOOL_OPTIONS` line before the exception is stderr from your environment, not part of the trace. If you tried the source launcher and got line numbers on every variant, that's a different compile path from the one I tested.

## Unknown Source in a stack trace: what each flag leaves behind

Look at the class files. `javap -v` prints the attributes; I counted them for each variant:

```log
default   LineNumberTable x5   SourceFile x1
-g        LineNumberTable x5   SourceFile x1   LocalVariableTable x5
-g:none   (none of them)
-g:lines  LineNumberTable x5   (no SourceFile)
-g:source SourceFile x1        (no LineNumberTable)
-g:vars   LocalVariableTable x5

```

And for a quick look at one method, `javap -l` shows the line table itself:

```log
$ javap -l -cp out_default Lab
Compiled from "Lab.java"
public class Lab {
  ...
  [... lines cut by me]
  static java.lang.String route(Lab$Event);
    LineNumberTable:
      line 13: 0
      line 14: 1
      line 15: 4
      line 16: 7

```

That `Compiled from "Lab.java"` line is the `SourceFile` attribute. If `javap -l` on your class prints no `Compiled from` line, you've found your answer.

Now the annotated version of the broken trace:

```log
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.trim()" because ...   (1)
	at Lab.signature(Unknown Source)                                                                      (2)
	at Lab.route(Unknown Source)
	at Lab.handle(Unknown Source)
	at Lab.main(Unknown Source)                                                                           (3)

```

**(1)** The message is untouched by debug flags. It names the method call that returned null (`Lab$Event.body()`), which is the real clue here, and it's still there. The helpful-NPE text comes from analysing bytecode, not from your line table.

**(2)** Class and method names always survive. They live in the constant pool and have to exist for the program to run. A frame without a line number is still a frame with a name, so you can still open `Lab.signature` and read it. You've lost precision, not the location.

**(3)** `Unknown Source` replaces the entire parenthesised part. There's no file name, no number.

Back to `out_lines`. `StackTraceElement.toString()` checks, in this order, whether the frame is native, whether the file name is null, and only then appends the file name and (if it's non-negative) the line. A null file name means `Unknown Source` and the line number never gets looked at. No `SourceFile`, no file name, so no line number printed even though the table was right there. I read that off the JDK 21 `StackTraceElement` source at `jdk-21+35`, through a summarising fetch tool rather than line by line, and the lab output agrees with it in all six variants.

## How the JVM decides to print Unknown Source

When a `Throwable` is constructed, the JVM walks the stack and records, per frame, the method and a bytecode index. It doesn't store a string. Line numbers are resolved from the `LineNumberTable` of that method, and the file name from `SourceFile` of the class, when the stack trace is materialised, and `StackTraceElement` is the object that carries the results. `Throwable.printStackTrace` calls `toString()` on each one.

So there are three outcomes for the parenthesised part, and they're a single `if` chain rather than three code paths:

```log
native method                 ->  (Native Method)
no file name                  ->  (Unknown Source)
file name, line < 0           ->  (Lab.java)
file name, line >= 0          ->  (Lab.java:9)

```

Native comes first. I took a thread parked in `Object.wait` and printed its stack from the outside with `t.getStackTrace()`:

```log
	at java.base/java.lang.Object.wait0(Native Method)
	at java.base/java.lang.Object.wait(Object.java:366)
	at java.base/java.lang.Object.wait(Object.java:339)
	at Lab.lambda$main$0(Lab.java:31)
	at java.base/java.lang.Thread.run(Thread.java:1583)

```

`wait0` is declared `native`, so there's no bytecode to map a line to. That's `(Native Method)`, and it's a normal frame, not a failure. Rebuild with `-g:none` and only our own frame changes: `Lab.lambda$main$0(Unknown Source)`. The JDK's own frames keep their line numbers because the JDK ships built with them.

The same file-name question has a second half. The prefix in front of the class name changed in JDK 9: `java.base/` is the module name, and a class loader name can sit in front of that. The source says the loader name is dropped for the built-in loaders (boot, platform, app), so you normally never see one. I wanted to see one anyway. A custom `URLClassLoader` with a name makes it visible. I put `Plug.class` in a jar compiled with `-g:none`, took it off the classpath entirely (my first attempt left `Plug.class` on the classpath too, the parent loader found it first, and I spent ten minutes wondering why my stripped jar printed `Plug.java:3`), and loaded it through a loader named `plugin`:

```log
java.lang.IllegalStateException: plugin failed
	at plugin//Plug.run(Unknown Source)
	at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
	at java.base/java.lang.reflect.Method.invoke(Method.java:580)
	at Lab.main(Lab.java:42)

```

`plugin//` is loader name, an empty module name (unnamed module), then the class. Same jar compiled with plain `-g`: `plugin//Plug.run(Plug.java:3)`. I'd seen a claim that newer formats show the jar name in place of the file. I didn't see that on 21.0.12.1, and I'd treat it as unverified unless you can show it on your JDK.

This took me more than a day to untangle, mostly because every wrong guess (JVM flag, JIT, stripped JDK, jar signing) produced a plausible story. The class file turned out to be the only thing that mattered. You get to skip that.

## Myths about this output

**"The JVM lost the line numbers."** The class never had them. You can't recover them at runtime; there's no flag on `java` that restores a table that isn't in the bytes.

**"Unknown Source means a JDK frame or a native library."** Not on 21\. JDK frames print `java.base/...(File.java:123)`. `Unknown Source` in *your* package means your class was compiled without `SourceFile`. When it appears on a third-party frame, the vendor's jar was built that way, or something rewrote it afterwards.

**"We turned on line numbers, so we're fine."** That's `out_lines`. `-g:lines` alone, a setting you can reach through a build-tool `debuglevel`, gets you the table and an `Unknown Source` trace. You need `source` as well. Maven's `debuglevel` accepts `none` or a comma-separated list from `lines`, `vars`, `source`, and appends it to `-g`; the compile mojo documents `debug` as defaulting to `true`. Put `lines,source` there, not just `lines`. I haven't checked Gradle's defaults against its docs for this run, so I won't state them as fact.

**"The line number is wrong."** Look at `route`. It spans lines 13 to 16 and the frame says `Lab.java:14`. The `LineNumberTable` above has entries for 13, 14, 15 and 16 at different bytecode offsets. The frame shows the line of the *instruction that was executing in that frame*, which here is the call to `signature(e)`, on line 14, not the start of the statement on 13 and not the end on 16\. A chained expression over many lines can point at the middle. Count by call, not by statement. Compilers that generate code for you (Lombok, Kotlin, annotation processors, I haven't run any of them for this article) sometimes map several generated lines to one source line, or to line 1, so trust line numbers a bit less in generated code.

**"Obfuscation did this, so ignore it."** If your release pipeline shrinks or obfuscates, stack traces from production stop pointing at your source. Tools like ProGuard and R8 have a way to keep `SourceFile` and `LineNumberTable` and a mapping file to translate traces back; from memory the keep option is `-keepattributes SourceFile,LineNumberTable`, and I haven't run either tool for this piece, so check your tool's docs before copying it. Android/ART is out of scope here.

## The fix, and what it costs

Compile with `-g`, or at least `-g:source,lines`.

- **Maven**: debug info is on by default (the `debug` parameter defaults to `true`). If you see `Unknown Source`, look for `<debug>false</debug>`, `<debuglevel>none</debuglevel>`, or `-g:none` in `compilerArgs`.
- **Gradle**: set `options.debug = true` or `options.compilerArgs += "-g"` explicitly if you're unsure, and verify with `javap`.
- **Anything that repackages the jar** (shaders, minifiers, obfuscators): check that class attributes survive the step. Run `javap -l` on a class *from the shipped artifact*, not from `target/classes`.

What it costs: class file size. For this lab's two classes (`Lab` and `Lab$Event`), `-g:none` was 4,850 bytes, the default 5,198 (about 7% more), and full `-g` 5,869 (about 21% more). Tiny classes exaggerate the ratio, and I didn't measure a real jar. I didn't measure runtime either; as far as I know the tables are only read when a trace is built or a debugger asks, but that's reasoning, not a benchmark.

What `-g:vars` buys you is separate. Look at what happens to the helpful NPE message when the failing expression is a local variable. Local.java:

```java
public class Local {
    static int len(String a) {
        String body = a.isEmpty() ? null : a;
        return body.length();
    }
    public static void main(String[] args) {
        len("");
    }
}

```

```log
== -g (first lines only, rest cut by me)
java.lang.NullPointerException: Cannot invoke "String.length()" because "body" is null
	at Local.len(Local.java:4)

== -g:none
java.lang.NullPointerException: Cannot invoke "String.length()" because "<local1>" is null
	at Local.len(Unknown Source)

== -g:vars
java.lang.NullPointerException: Cannot invoke "String.length()" because "body" is null
	at Local.len(Unknown Source)

```

`<local1>` is the NPE message falling back to a slot number when it can't find the variable's name. In the `Lab` case the message said `the return value of "Lab$Event.body()"`, which needs no variable table, so it survived `-g:none`. Whether you get a name or a slot depends on what the null expression is. That's what stripping debug info actually costs you in this series' favourite exception.

What isn't a fix: restarting with different `java` flags, adding `-XX:+ShowHiddenFrames`, or staring at the source harder. And don't strip debug info to hide your source. Class files are trivial to decompile anyway; all you'd be doing is making your own incident reports worse.

## Catching it in production

Two cheap checks that would have saved me hours:

1. In CI, run `javap -l` (or `javap -v | grep -c LineNumberTable`) on one representative class from the final artifact and fail the build if it reports none.
2. In your log search, alert on the string `Unknown Source` appearing in frames from your own packages. It's a deployment bug, and it will be there for every trace until someone fixes the build.

Aggregators fingerprint errors by frames. A change from `Lab.java:9` to `Unknown Source` after a rebuild will look like a new error type in some tools and a different grouping in others. That's one more reason to catch it before release.

Version notes, honestly scoped. Everything above was run on OpenJDK 21.0.12.1 only. I didn't run 8, 17 or 25 for this article. The `java.base/` and loader-name prefixes are JDK 9+ behaviour (JDK 8 prints plain `at java.util.ArrayList.get(ArrayList.java:...)`), which I know from the module system's introduction rather than from a JDK 8 run today. `Unknown Source` itself is far older: a search for it turns up forum threads from 2003 and 2006, and a JBS request ([JDK-5040214](https://bugs.openjdk.org/browse/JDK-5040214?ref=ggorantala.dev)) titled "Exception stacktraces should show linenumbers when available". I only saw its title in a search result.

## Screenshot this bit

- `(Unknown Source)` means the class has no `SourceFile` attribute. Line numbers aren't consulted at all in that case.
- `-g:lines` without `source` still prints `Unknown Source`. Ask for both, or use `-g`.
- `(Lab.java)` with no number means `SourceFile` is present and `LineNumberTable` isn't. `(Native Method)` is a normal native frame.
- Check any artifact with `javap -l`: no `Compiled from "X.java"` line, no source name in traces.
- A multi-line statement's frame shows the line of the call in progress, not the first line of the statement.

## Things people paste into search

**Why does my Java stack trace say Unknown Source instead of a line number?**  
The class was compiled without the `SourceFile` attribute (`-g:none`, or a debug level without `source`), or something stripped it from the jar. Check with `javap -l`. Verified on OpenJDK 21.0.12.1.

**How do I get line numbers in Java stack traces?**  
Compile with `-g`, or `-g:source,lines` at minimum. In Maven, don't set `debug` to false or `debuglevel` to `none`; the default is already on. Then rebuild and redeploy; no `java` runtime flag can add them back.

**What does "Native Method" mean in a stack trace?**  
The frame is a method declared `native`, so there's no bytecode and no line number to show. `Object.wait0(Native Method)` on JDK 21 is an example. It's normal. The frame below it is where your Java code called into it. The overall reading routine is in [How to Read a Java Stack Trace](https://ggorantala.dev/reading-java-stack-traces/?ref=ggorantala.dev).

**Why is the line number in the stack trace wrong?**  
Usually it isn't: the frame shows the line of the call that was running, which in a multi-line statement can be the second or third line. If the jar was shrunk or obfuscated, the line may belong to the pre-processed code. For odd method names in the frames see [Strange Java Stack Frames](https://ggorantala.dev/java-stack-trace-strange-frames/?ref=ggorantala.dev); for what happens to the `Caused by` lines when this applies to a chain, see [Caused by in Java Stack Traces](https://ggorantala.dev/java-caused-by-root-cause/?ref=ggorantala.dev).