Handle Exceptions in Java Streams: Wrap, Skip or Collect

Learn to handle exceptions in Java streams by wrapping checked exceptions, skipping bad elements with Optional or mapMulti, or collecting errors as values.

Decision diagram for handling a checked exception thrown inside a Java stream: fail fast, skip the element, or collect the error

A lambda inside a Java stream cannot throw a checked exception, because the functional interfaces that stream methods accept, such as Function and Predicate, declare no checked exceptions. To handle exceptions in Java streams, we catch the checked exception inside the stream and either rethrow it as an unchecked exception or turn the failure into a value that the pipeline can carry on with.

We meet this problem whenever a map() step calls a method with a throws clause, such as parsing user input, reading files with Files.readString() or calling a remote API. The right handling depends on whether one bad element should stop the whole batch or not.

The following example parses a list of links with the URI constructor, which throws the checked URISyntaxException. The first pipeline skips the bad link, and the second one stops at it.

List<String> links = List.of("https://example.com/a", "bad link", "https://example.com/b");

List<URI> valid = links.stream().map(LinkParser::tryParse).flatMap(Optional::stream).toList();   // [https://example.com/a, https://example.com/b]
List<URI> strict = links.stream().map(LinkParser::parseOrThrow).toList();                         // IllegalArgumentException: Bad link: bad link

Both pipelines call small helper methods that hold the try-catch, so the stream code stays one line long. We start with the compiler error itself, and go through failing fast, skipping bad elements, collecting the errors as values, a reusable wrapper and the Failable classes of Apache Commons Lang.

1. Why a Lambda Cannot Throw a Checked Exception

The method Function.apply() has no throws clause. A lambda passed to map() is an implementation of that method, so the compiler applies the same rule as for any overriding method, and a checked exception thrown in the lambda body must be caught there. The checked exceptions rule is the same in a stream as anywhere else, only the place where we can catch it moves into the lambda.

Decision diagram for handling a checked exception thrown inside a Java stream: fail fast, skip the element, or collect the error
One bad element either stops the stream, gets skipped, or travels on as a value
// does not compile: unreported exception URISyntaxException; must be caught or declared to be thrown
List<URI> uris = links.stream()
        .map(URI::new)
        .toList();

Adding throws URISyntaxException to the enclosing method does not help, because the lambda body is checked against Function.apply() and not against the method that builds the stream. We have three choices once we catch the exception, and the diagram groups the options by what happens to the rest of the stream.

  • Fail fast. We wrap the checked exception in an unchecked one, and the terminal operation throws it to the caller.
  • Skip the element. We return an empty Optional or push nothing with mapMulti(), and log the failure.
  • Collect the errors. We return a result object that holds either the value or the error, and decide after the stream finishes.

2. Failing Fast With an Unchecked Exception

When one bad element means the whole batch is wrong, we catch the checked exception and rethrow it as an unchecked exception with the original as its cause. The terminal operation stops at the first failure and throws the unchecked exception to our code, which can catch it outside the stream.

The inline version puts the try-catch block in the lambda. It works, but a five-line lambda hides the pipeline, so we use it only for one-off code.

List<String> trusted = List.of("https://example.com/a", "https://example.com/b");

List<URI> uris = trusted.stream()
        .map(link -> {
            try {
                return new URI(link);
            } catch (URISyntaxException e) {
                throw new IllegalArgumentException("Bad link: " + link, e);
            }
        })
        .toList();                     // [https://example.com/a, https://example.com/b]

The cleaner version moves the try-catch into a named method, and the stream refers to it with a method reference. For I/O code, we wrap IOException in UncheckedIOException, which the JDK added for wrapping I/O errors in lambdas.

class LinkParser {

    static URI parseOrThrow(String link) {
        try {
            return new URI(link);
        } catch (URISyntaxException e) {
            throw new IllegalArgumentException("Bad link: " + link, e);
        }
    }

    static Optional<URI> tryParse(String link) {
        try {
            return Optional.of(new URI(link));
        } catch (URISyntaxException e) {
            System.getLogger("links").log(System.Logger.Level.WARNING, "Skipping " + link);
            return Optional.empty();
        }
    }

    static String readOrThrow(Path file) {
        try {
            return Files.readString(file);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }
}
List<String> trusted = List.of("https://example.com/a", "https://example.com/b");
List<String> links = List.of("https://example.com/a", "bad link", "https://example.com/b");

List<URI> ok = trusted.stream().map(LinkParser::parseOrThrow).toList();       // [https://example.com/a, https://example.com/b]
List<URI> stopped = links.stream().map(LinkParser::parseOrThrow).toList();    // IllegalArgumentException: Bad link: bad link

Before writing a wrapper, we check whether the API has an unchecked variant. For example, URI.create() throws IllegalArgumentException instead of URISyntaxException, and URLEncoder.encode(String, Charset) has no checked exception since Java 10.

List<String> trusted = List.of("https://example.com/a", "https://example.com/b");
List<String> links = List.of("https://example.com/a", "bad link", "https://example.com/b");

List<URI> created = trusted.stream().map(URI::create).toList();    // [https://example.com/a, https://example.com/b]
List<URI> rejected = links.stream().map(URI::create).toList();     // IllegalArgumentException: Illegal character in path at index 3: bad link

A payment import is a good fit for failing fast. If one line of a bank file cannot be parsed, booking the other lines would leave the accounts half updated, so the import stops and reports the line.

3. Skipping Bad Elements With Optional or mapMulti()

When the good elements are still useful on their own, we skip the bad ones. The helper returns an Optional that is empty on failure, and flatMap(Optional::stream) drops the empty ones. The Optional.stream() method exists since Java 9, so we no longer end up with a List<Optional<URI>>.

List<String> links = List.of("https://example.com/a", "bad link", "https://example.com/b");

List<URI> parsed = links.stream().map(LinkParser::tryParse).flatMap(Optional::stream).toList();   // [https://example.com/a, https://example.com/b]

The mapMulti() method (Java 16) does the same in one step without creating Optional objects. The lambda pushes zero or one element to the consumer, and the try-catch decides which.

List<String> links = List.of("https://example.com/a", "bad link", "https://example.com/b");

List<URI> pushed = links.stream()
        .<URI>mapMulti((link, sink) -> {
            try {
                sink.accept(new URI(link));
            } catch (URISyntaxException e) {
                System.getLogger("links").log(System.Logger.Level.WARNING, "Skipping " + link);
            }
        })
        .toList();                     // [https://example.com/a, https://example.com/b]

Skipping is only safe when we log the failure or count it somewhere. A stream that returns null on failure and filters it out loses the error, and nobody finds out why a link is missing. A feed reader that imports a user’s bookmark file is a typical case for skipping, because one broken link should not block the other ninety-nine.

4. Collecting Errors as Values With a Result Type

Sometimes we need both lists, the parsed values and the failures with their input, for example to show a report to the user. Java has no built-in Either or Try type, but a sealed interface with two records gives us one in a few lines.

sealed interface Result<T> permits Ok, Failed {}
record Ok<T>(T value) implements Result<T> {}
record Failed<T>(String input, Exception error) implements Result<T> {}
static Result<URI> parse(String link) {
    try {
        return new Ok<>(new URI(link));
    } catch (URISyntaxException e) {
        return new Failed<>(link, e);
    }
}
List<String> links = List.of("https://example.com/a", "bad link", "https://example.com/b");

List<Result<URI>> results = links.stream().map(link -> parse(link)).toList();
List<String> report = results.stream()
        .map(r -> switch (r) {
            case Ok<URI>(URI uri) -> "OK " + uri.getHost();
            case Failed<URI>(String input, Exception error) -> "FAILED " + input;
        })
        .toList();                     // [OK example.com, FAILED bad link, OK example.com]

The switch uses record patterns (Java 21), and because Result is sealed, the compiler checks that both cases are handled. To split the results, we use Collectors.partitioningBy(r -> r instanceof Ok), or filter each type.

List<Result<URI>> outcomes = Stream.of("https://example.com/a", "bad link").map(link -> parse(link)).toList();
long failures = outcomes.stream().filter(r -> r instanceof Failed).count();   // 1

A config loader is a good example. At startup, it reads ten files, and when two of them fail, the error message should name both files, not only the first one that a fail-fast stream would report.

5. Writing a Reusable Wrapper for Throwing Lambdas

When many streams in a project call methods with checked exceptions, we write the try-catch once. A small functional interface declares throws Exception, and a static method adapts it to a standard Function.

@FunctionalInterface
interface ThrowingFunction<T, R> {
    R apply(T t) throws Exception;
}
static <T, R> Function<T, R> unchecked(ThrowingFunction<T, R> f) {
    return t -> {
        try {
            return f.apply(t);
        } catch (RuntimeException e) {
            throw e;
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        } catch (Exception e) {
            throw new RuntimeException("Failed for " + t, e);
        }
    };
}
List<String> trusted = List.of("https://example.com/a", "https://example.com/b");
List<String> links = List.of("https://example.com/a", "bad link", "https://example.com/b");

List<URI> wrapped = trusted.stream().map(unchecked(URI::new)).toList();     // [https://example.com/a, https://example.com/b]
List<URI> failed = links.stream().map(unchecked(URI::new)).toList();        // RuntimeException: Failed for bad link

The first catch rethrows unchecked exceptions unchanged, so a NullPointerException keeps its type. If a project needs the same for Predicate or Consumer, we add one interface and one adapter method for each.

6. Using Failable From Apache Commons Lang

The Apache Commons Lang library ships ready-made versions of these wrappers. The class Streams.FailableStream accepts lambdas that throw checked exceptions, and Failable.asFunction() adapts one lambda for a normal stream. We add the current version of the dependency.

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.21.0</version>
</dependency>
List<String> trusted = List.of("https://example.com/a", "https://example.com/b");
List<String> links = List.of("https://example.com/a", "bad link", "https://example.com/b");

List<URI> viaFailable = Streams.failableStream(trusted).map(URI::new).collect(Collectors.toList());   // [https://example.com/a, https://example.com/b]
List<URI> viaAdapter = trusted.stream().map(Failable.asFunction(URI::new)).toList();              // [https://example.com/a, https://example.com/b]
List<URI> notParsed = links.stream().map(Failable.asFunction(URI::new)).toList();                  // UndeclaredThrowableException caused by URISyntaxException

Both options fail fast. A checked exception other than IOException arrives as java.lang.reflect.UndeclaredThrowableException with the original exception as its cause, so code that catches it calls getCause(). The library saves us the wrapper code, but it adds a dependency for something a ten-line method also does.

7. Comparing Ways to Handle Exceptions in Java Streams

The choice follows from what should happen to the remaining elements after a failure. A fail-fast stream is the default for data that must be complete, whereas skipping and collecting fit user input and batch imports.

ApproachBad elementError keptExtra code
try-catch in the lambdaStops or skips, as codedYes, if rethrownNone, but long lambdas
Helper method + rethrowStops the streamYes, as the causeOne method per call
Optional or mapMulti()SkippedOnly if loggedOne method
Sealed Result typeKept as FailedYes, with the inputInterface and two records
Generic unchecked() wrapperStops the streamYes, as the causeOne interface, one method
Commons Lang FailableStops the streamYes, as the causeA library dependency

We avoid catching Exception in the lambda and returning null or an empty string. That code compiles and runs, but it turns a parse error into wrong data further down the pipeline.

8. Real-World Example of Loading Several Files

A reporting service reads a list of template files at startup. A missing template should not stop the other templates from loading, but the startup log must name every file that failed, so the service collects the failures with the Result type from section 4.

static Result<String> load(Path file) {
    try {
        return new Ok<>(Files.readString(file));
    } catch (IOException e) {
        return new Failed<>(file.toString(), e);
    }
}
static List<String> loadTemplates(List<Path> files) {
    Map<Boolean, List<Result<String>>> byOutcome = files.stream()
            .map(file -> load(file))
            .collect(Collectors.partitioningBy(r -> r instanceof Ok));
    byOutcome.get(false).forEach(f -> System.getLogger("templates")
            .log(System.Logger.Level.ERROR, "Cannot load " + ((Failed<String>) f).input()));
    return byOutcome.get(true).stream().map(r -> ((Ok<String>) r).value()).toList();
}

The method Files.readString() throws IOException, and load() catches it next to the call, so the stream only ever sees Result objects. The same pattern works for HTTP calls, JSON parsing and database lookups inside a stream.

9. Stream Exception Handling FAQs

Checked exceptions in lambdas, a failing element, forEach(), sneaky throws and long try-catch blocks are the topics that come up in code reviews.

9.1. Can a lambda throw a checked exception?

Yes, when the functional interface it implements declares that exception. For example, Callable.call() declares throws Exception, so a lambda passed to an ExecutorService can throw IOException. The stream methods take Function, Predicate and Consumer, which declare none.

9.2. Does an exception stop the whole stream?

Yes. An exception thrown in any stage ends the terminal operation, and the elements after the failing one are not processed in a sequential stream. In a parallel stream, other threads may have processed some elements already, and the terminal operation throws an exception of the same type in the calling thread.

9.3. How do we handle exceptions in Stream.forEach()?

The same way as in map(), because forEach() takes a Consumer that declares no checked exceptions. If the code must throw a checked exception to the caller, a plain for loop is the simpler choice, because a loop body can throw anything the method declares.

9.4. Should we use sneaky throws or Lombok @SneakyThrows?

We avoid it in application code. The sneaky-throw trick uses generics to throw a checked exception without declaring it, so the caller cannot write a catch clause for that exception type, because the compiler reports that the try block never throws it. An unchecked wrapper with the original exception as its cause keeps the information and plays well with exception handlers.

9.5. Is try-catch inside a lambda bad practice?

No, it is only hard to read when the block is long. Moving it into a named method keeps the pipeline readable and gives the error handling a name, such as tryParse() or parseOrThrow(), that tells the reader which strategy the stream uses.

10. Conclusion

Stream methods accept functional interfaces without a throws clause, so a checked exception must be caught inside the lambda. From there, we decide what a failure means for the rest of the data.

To stop at the first error, we rethrow an unchecked exception such as UncheckedIOException with the original as the cause. To keep going, we return an empty Optional, push nothing with mapMulti(), or return a sealed Result that keeps the error with its input. A small unchecked() wrapper or Commons Lang Failable removes the repeated try-catch code, and the Java Stream guide covers the other stream operations used here.

11. References

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.