On This Page
Exception in thread "main" java.io.IOException: flush failed on orders.csv
at Lab$Sink.close(Lab.java:13)
at Lab.exportFinally(Lab.java:28)
at Lab.main(Lab.java:92)
"Flush failed." That's everything the log told me, and it's the wrong answer. The disk was full. The exception that said so got thrown, then replaced by the next line of a finally block before anyone could see it. Suppressed exceptions in Java exist to fix exactly this, which is why a Suppressed: block in a trace is good news, even though it looks like clutter.
The error that never reached the log
Same code, same failure, but written with try-with-resources. This is OpenJDK 21.0.12.1 on Linux, a CSV exporter that writes a row to a sink and then closes it. Plain java -cp . Lab twr, console output, nothing cleaned up:
Exception in thread "main" java.io.IOException: disk full while writing orders.csv
at Lab$Sink.write(Lab.java:10)
at Lab.exportTwr(Lab.java:19)
at Lab.main(Lab.java:91)
Suppressed: java.io.IOException: flush failed on orders.csv
at Lab$Sink.close(Lab.java:13)
at Lab.exportTwr(Lab.java:18)
... 1 more
Two exceptions, and neither is wrapped in the other. The first line is the one that propagated: disk full. The block with the extra tab, Suppressed:, is a second IOException, thrown from close() while the first one was already on its way up. If you're looking at a trace like this and wondering whether you're reading one problem or two, the answer is two, and the tab is how you tell.
Everything below follows from that. On the other JDKs I couldn't run (8, 17, 25) I'd expect the same block, since the mechanism dates from JDK 7, but I only have output from 21 and I'll keep saying so.
What it's actually telling you
The exception at the top is the one your code was dying of. The Suppressed: exception is something that went wrong afterwards, during cleanup, and that Java refused to throw because it already had a bigger problem in flight. It's not ignored. It's attached.
My first instinct, the first time I saw one, was to read Suppressed as "harmless, the JVM swallowed it". That's backwards. Nothing swallowed anything; the JVM kept the second failure and hung it off the first so you'd get both. It's often a consequence of the primary (a flush fails because the disk is full), and sometimes it's an unrelated second problem that you'd never have found otherwise.
Open the primary's top frame first, Lab$Sink.write. Then read the suppressed block and decide whether it's the same story.
Get the same output in two minutes
JDK 21, nothing else. I used OpenJDK 21.0.12.1; any 21 will do. Save this as Lab.java and keep the blank lines exactly as they are, because the line numbers in every trace depend on them.
import java.io.IOException;
public class Lab {
static class Sink implements AutoCloseable {
final String name;
final boolean failOnClose;
Sink(String name, boolean failOnClose) { this.name = name; this.failOnClose = failOnClose; }
void write(String row) throws IOException {
throw new IOException("disk full while writing " + name); // the real failure
}
@Override public void close() throws IOException {
if (failOnClose) throw new IOException("flush failed on " + name); // the second failure
}
}
static void exportTwr() throws IOException {
try (Sink out = new Sink("orders.csv", true)) {
out.write("id,total");
}
}
static void exportFinally() throws IOException {
Sink out = new Sink("orders.csv", true);
try {
out.write("id,total");
} finally {
out.close(); // this exception replaces the one above
}
}
static void exportTwo() throws IOException {
try (Sink a = new Sink("a.csv", true); Sink b = new Sink("b.csv", true)) {
b.write("id,total");
}
}
static void exportWrapped() {
try (Sink out = new Sink("orders.csv", true)) {
out.write("id,total");
} catch (IOException e) {
throw new IllegalStateException("export failed", e);
}
}
static class NoSuppress extends Exception {
NoSuppress(String m) { super(m, null, false, true); } // enableSuppression = false
}
static void exportNoSuppress() throws Exception {
try (Sink out = new Sink("orders.csv", true)) {
throw new NoSuppress("row 3 rejected");
}
}
static void exportRootAndSuppressed() throws Exception {
try (Sink out = new Sink("orders.csv", true)) {
try {
out.write("id,total");
} catch (IOException e) {
throw new IllegalStateException("export failed", e);
}
}
}
static void exportManual() throws IOException {
Sink out = new Sink("orders.csv", true);
IOException primary = null;
try {
out.write("id,total");
} catch (IOException e) {
primary = e;
throw e;
} finally {
if (primary == null) {
out.close();
} else {
try { out.close(); } catch (IOException s) { primary.addSuppressed(s); }
}
}
}
static void exportCloseOnly() throws IOException {
try (Sink out = new Sink("orders.csv", true)) {
// body succeeds, close() fails
}
}
public static void main(String[] args) throws Exception {
switch (args[0]) {
case "twr" -> exportTwr();
case "finally" -> exportFinally();
case "two" -> exportTwo();
case "wrapped" -> exportWrapped();
case "nosup" -> exportNoSuppress();
case "both" -> exportRootAndSuppressed();
case "manual" -> exportManual();
case "closeonly" -> exportCloseOnly();
case "self" -> { Exception e = new Exception("x"); e.addSuppressed(e); }
case "null" -> { Exception e = new Exception("x"); e.addSuppressed(null); }
case "get" -> {
try { exportTwo(); } catch (IOException e) {
for (Throwable s : e.getSuppressed()) System.out.println(s.getMessage());
System.out.println(e.getSuppressed().length);
}
Exception n = new NoSuppress("z");
n.addSuppressed(new Exception("ignored"));
System.out.println(n.getSuppressed().length);
}
default -> throw new IllegalArgumentException(args[0]);
}
}
}
Compile and run each mode:
javac Lab.java
java -cp . Lab twr # try-with-resources, the trace above
java -cp . Lab finally # the old pattern, the trace at the top
java -cp . Lab two # two resources, both fail on close
java -cp . Lab wrapped # catch + rethrow around try-with-resources
java -cp . Lab both # a cause and a suppressed exception on the same throwable
java -cp . Lab nosup # suppression switched off
java -cp . Lab closeonly # body succeeds, close() fails
java -cp . Lab manual # the try-with-resources shape written out by hand
java -cp . Lab get # reading getSuppressed() in code
If your twr trace says Lab.java:18 on the close frame and Lab.java:19 on the write frame, you've copied it exactly. If the numbers are off by a line or two, you lost a blank line somewhere.
The variations are where it gets useful. Here's two, same code but two resources, and both of them fail on close:
Exception in thread "main" java.io.IOException: disk full while writing b.csv
at Lab$Sink.write(Lab.java:10)
at Lab.exportTwo(Lab.java:34)
at Lab.main(Lab.java:93)
Suppressed: java.io.IOException: flush failed on b.csv
at Lab$Sink.close(Lab.java:13)
at Lab.exportTwo(Lab.java:33)
... 1 more
Suppressed: java.io.IOException: flush failed on a.csv
at Lab$Sink.close(Lab.java:13)
at Lab.exportTwo(Lab.java:33)
... 1 more
Two Suppressed: blocks, in this order: b.csv first, a.csv second. That's reverse of declaration, because try-with-resources closes in reverse. I assumed it'd read top-to-bottom like the try (...) header. It doesn't.
And closeonly, where the body succeeds and only close() fails:
Exception in thread "main" java.io.IOException: flush failed on orders.csv
at Lab$Sink.close(Lab.java:13)
at Lab.exportCloseOnly(Lab.java:86)
at Lab.main(Lab.java:98)
No Suppressed:, no ceremony. With no primary exception to attach to, the close failure becomes the exception. Keep that in mind for later: a trace with no Suppressed: block doesn't prove nothing else failed.
Suppressed exceptions in Java, read tab by tab
The twr trace again, with markers. These are my annotations on the real output, not something the JVM prints:
Exception in thread "main" java.io.IOException: disk full while writing orders.csv (1)
at Lab$Sink.write(Lab.java:10) (2)
at Lab.exportTwr(Lab.java:19)
at Lab.main(Lab.java:91)
Suppressed: java.io.IOException: flush failed on orders.csv (3)
at Lab$Sink.close(Lab.java:13) (4)
at Lab.exportTwr(Lab.java:18) (5)
... 1 more (6)
- The primary exception. This is what
catchblocks above you will receive, and whatgetMessage()will return. It's the one the body of thetrythrew. - Its frames, newest first, as in any trace (the hub article has the frame format).
- One tab, then
Suppressed:. The tab says "I belong to the exception above me", not "I'm a new top-level failure". - The suppressed exception's own frames sit one tab deeper, so two tabs. Its top frame is where it was created, in
close(). exportTwr(Lab.java:18). Line 18 is thetry (line, not line 19 wherewritewas called. The compiler attributes theclose()call to the line that opened the resource, which is where you'd go looking for the resource anyway, so it's oddly handy.... 1 morehides one frame that this block shares with the primary's trace. Here it'sLab.main. Notice thatexportTwrisn't hidden even though it's the same method: the line numbers differ (18 vs 19), so the frames aren't equal. The same counting rule as in the Caused by article, with a different "enclosing" trace.
Two more shapes you'll hit. Run both, where the body wraps the IOException and the close also fails:
Exception in thread "main" java.lang.IllegalStateException: export failed
at Lab.exportRootAndSuppressed(Lab.java:61)
at Lab.main(Lab.java:96)
Suppressed: java.io.IOException: flush failed on orders.csv
at Lab$Sink.close(Lab.java:13)
at Lab.exportRootAndSuppressed(Lab.java:57)
... 1 more
Caused by: java.io.IOException: disk full while writing orders.csv
at Lab$Sink.write(Lab.java:10)
at Lab.exportRootAndSuppressed(Lab.java:59)
... 1 more
Suppressed: comes before Caused by:. I'd have bet the other way round. And notice where each one attaches: the close failure is suppressed onto the IllegalStateException, because that's what was in flight when close() ran. The Caused by block is the original IOException, with no suppressed exception on it.
Now the opposite. Run wrapped, where the catch sits outside the try-with-resources:
Exception in thread "main" java.lang.IllegalStateException: export failed
at Lab.exportWrapped(Lab.java:42)
at Lab.main(Lab.java:94)
Caused by: java.io.IOException: disk full while writing orders.csv
at Lab$Sink.write(Lab.java:10)
at Lab.exportWrapped(Lab.java:40)
... 1 more
Suppressed: java.io.IOException: flush failed on orders.csv
at Lab$Sink.close(Lab.java:13)
at Lab.exportWrapped(Lab.java:39)
... 1 more
Here the Suppressed: hangs under the Caused by:, indented one tab, even though it's a different place on the page. The catch clause runs after the resources have been closed, so by the time IllegalStateException was constructed, the close failure was already stuck to the IOException. Same code idea, different attachment point, and it changes which exception you have to call getSuppressed() on.
Why Suppressed prints first, and why it indents
I read Throwable.java at jdk-21+35 for this, and the printing part is short. (It took me longer than I'd like to admit to stop guessing and read it; the fetch tool I used summarised the file rather than handing me raw text, so treat my quotes as paraphrase and check the link.) What it does for the top-level throwable: print this, print each frame with \tat , then loop over getSuppressed() calling printEnclosedStackTrace with the caption Suppressed: and the prefix "\t", and only after that print the cause with Caused by: and an empty prefix. That's the whole reason suppressed comes first.
The indent rule falls out of the prefix. Suppressed exceptions get the parent's prefix plus a tab, so each nesting level adds one. Causes reuse the parent's prefix unchanged. A cause of a suppressed exception therefore sits at the suppressed exception's indent. I built one by hand to see it (this is a small separate program, not Lab.java), and with each real tab written as <TAB> (my substitution, the JVM doesn't print that) it looks like this:
Exception in thread "main" java.lang.IllegalStateException: export failed
<TAB>at Nest.main(Nest.java:7)
<TAB>Suppressed: java.io.IOException: flush failed
<TAB><TAB>at Nest.close(Nest.java:4)
<TAB><TAB>at Nest.main(Nest.java:8)
<TAB><TAB>Suppressed: java.io.IOException: fd release failed
<TAB><TAB><TAB>at Nest.close(Nest.java:4)
<TAB><TAB><TAB>at Nest.main(Nest.java:9)
<TAB>Caused by: java.net.SocketException: connection reset
<TAB><TAB>at Nest.main(Nest.java:10)
The Caused by: (the SocketException) lines up with the Suppressed: it belongs to, one tab in, and its frames get two. The nested fd release failed is two tabs deep. If you've ever squinted at an indented trace and lost track of which block owned which, this is why: indentation is the only structure there is.
The ... N more count uses the same method as for causes. Each block is compared, frame by frame from the bottom, against the trace of the throwable that contains it, and the shared tail is replaced with the count. Tail only. It stops at the first mismatch.
The storage side is also small. addSuppressed throws a NullPointerException for null (message Cannot suppress a null exception.) and an IllegalArgumentException for suppressing yourself. That second one has a surprise in it. My self run:
Exception in thread "main" java.lang.IllegalArgumentException: Self-suppression not permitted
at java.base/java.lang.Throwable.addSuppressed(Throwable.java:1096)
at Lab.main(Lab.java:99)
Caused by: java.lang.Exception: x
... 1 more
The IllegalArgumentException carries the offending exception as its cause, so you get a Caused by: block for something that is really an argument. The source passes the exception into the constructor on purpose. After the checks, addSuppressed appends to a list, and getSuppressed() returns an array in the order things were added. That's why two printed b.csv before a.csv: javac's generated code adds suppressed exceptions as it closes, and it closes in reverse. I also see java.base/ in front of java.lang.Throwable.addSuppressed, which is the module prefix JDK 9 added to frames; on JDK 8 that frame has no prefix.
The protected four-argument constructor takes enableSuppression. Pass false and the list is null, addSuppressed returns silently and getSuppressed() is empty. My nosup run, where the close failure vanished, shows what that looks like:
Exception in thread "main" Lab$NoSuppress: row 3 rejected
at Lab.exportNoSuppress(Lab.java:52)
at Lab.main(Lab.java:95)
No Suppressed:, and no error either. The close failure happened (that sink fails on every close) and the trace gives no hint. That constructor is meant for exceptions you throw by the million, where you've measured the cost. It's the same constructor that can turn off the stack trace itself, which is a different mess, covered in where did my stack trace go.
As for history: Throwable.addSuppressed and try-with-resources arrived together in JDK 7. Before that, the only tool was finally, which is the first trace in this post.
Looks the same, means something else
"Suppressed means it's harmless." Covered above. It means a second failure that didn't get to propagate. Treat it like any other exception until you've read it.
"Suppressed is a kind of cause." It isn't. getCause() follows the Caused by chain and never touches suppressed exceptions. If you wrote a root-cause loop like the one in the Caused by article, it will walk straight past every Suppressed: block. Two relationships, two methods: cause means "because of", suppressed means "also happened".
"The finally version does the same thing." Look at the first trace again. It reported flush failed. The disk-full exception is gone and nothing records that it existed. manual, the shape of what javac generates for try-with-resources, written out by hand (catch, remember the primary, add the close failure to it), gives:
Exception in thread "main" java.io.IOException: disk full while writing orders.csv
at Lab$Sink.write(Lab.java:10)
at Lab.exportManual(Lab.java:70)
at Lab.main(Lab.java:97)
Suppressed: java.io.IOException: flush failed on orders.csv
at Lab$Sink.close(Lab.java:13)
at Lab.exportManual(Lab.java:78)
... 1 more
That's the same output as twr, which tells you what the compiler is doing for you. Don't write it by hand; I included it so the equivalence is visible.
"No Suppressed: means a clean close." Not quite. closeonly showed a close failure with no primary printed as the exception itself. nosup showed a failure erased by the primary's own constructor. And a resource whose close() catches and logs its own errors reports nothing at all. Absence of the block tells you less than you'd like.
"The suppressed one is the real bug." Sometimes. A failed flush after a failed write is usually downstream. But a failed close() on the second resource (the a.csv case) is often an independent problem, and it's only visible because both are listed.
What to change in your code
Use try-with-resources and stop writing finally { x.close(); }. If a close() can throw, the difference between the first and second trace in this post is the whole case for it.
Don't catch exceptions inside close() and log-and-drop them unless you mean it. If it throws IOException, let it. The caller's try-with-resources will do the right thing with it, whether that's throwing it or suppressing it.
If you can't use try-with-resources (older code, a resource that doesn't implement AutoCloseable), write the manual shape and call addSuppressed yourself. Cost: it's easy to get subtly wrong, and I'd use it only if there's no alternative.
Log the throwable, not its message: log.error("export failed", e), not log.error(e.getMessage()). The message is one string from one exception. The suppressed exceptions live on the object and only a trace-printing path gets them out. More in the logging article.
Reading them in code is one loop:
for (Throwable s : e.getSuppressed()) {
System.out.println(s.getMessage());
}
My get mode ran this on the two-resource exception and printed flush failed on b.csv, flush failed on a.csv, then the count 2. For the NoSuppress exception it printed 0 even after addSuppressed(...).
What I'd skip: enableSuppression=false in your own exception types. The saving is small and the cost is exactly the thing this post is about. The design side, messages and chained causes and when to define a custom type, is in writing exceptions that produce useful traces.
Which JDK versions print what
The part I'm sure of: on OpenJDK 21.0.12.1 the Suppressed: format, the order (before Caused by:), the one-tab indent and the ... N more rule are what the traces above show, and the code is what I read at jdk-21+35. The part I haven't run: JDK 8, 17 and 25. I expect them to print the same structure, since the mechanism is from JDK 7 and I know of no change to the printer, but that's an expectation. The visible difference you will see on 8 is the missing java.base/ module prefix on JDK frames.
Logging libraries are the other half. I did not run Logback or Log4j2 this time, so I can't tell you how your version renders a Suppressed: block, and I won't guess. It's a ten-line check, so do it: copy exportTwo() into your service, log log.error("export failed", e), and look at the output. You should see two Suppressed: blocks. If you see none, your encoder or a filter is dropping them. And if you ship logs, check that the shipper treats tab-indented lines as part of the same event; a block that starts with a tab and the word Suppressed: is easy for a naive multi-line rule to cut off from its parent. I haven't tested any particular shipper.
Takeaways
- A
Suppressed:block is a second exception that happened while the first was propagating, usually insideclose(). It's real. Read it. - One tab means "belongs to the exception above". Suppressed prints before
Caused by:, and a suppressed exception's own cause is indented with it. - Try-with-resources closes in reverse order, so the suppressed list reads in reverse order of declaration.
- A
finally { close(); }that throws replaces the original exception. That's the failure mode this whole feature fixes. getCause()never returns suppressed exceptions. If you look for root causes in code, loop overgetSuppressed()too.
Quick answers
What does Suppressed mean in a Java stack trace?
It marks a second exception that occurred while the first was already propagating, most often a failure in a resource's close() during try-with-resources. Java attaches it to the primary exception with addSuppressed instead of letting it replace it. Since JDK 7.
Why does Suppressed show up before Caused by?
Because Throwable.printStackTrace prints the exception's own frames, then every suppressed exception, then the cause. I read this in Throwable.java at jdk-21+35 and saw it in the both run on 21.
How do I get suppressed exceptions in code?
Call getSuppressed() on the exception that was caught. It returns an array, empty if there are none, in the order they were added. Remember that with a catch outside try-with-resources they hang off the original exception, not the one you rethrow.
Why is my Suppressed exception missing?
Three causes I could reproduce or read: the primary exception was built with enableSuppression=false; there was no primary at all, so the close failure was thrown as the main exception; or your logging only wrote getMessage(). For the logging one, see how to log exceptions properly.
Start with how to read a Java stack trace if you landed here first.