Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Filtering, Transforming, and Aggregating Collections with Java Streams

Good to see the collections refresh turn into something practical. In the previous lesson, you chose List, Set, or Map based on the operation the application needs to support. This lesson uses those collections as sources for Java Stream pipelines.

A Stream pipeline lets you state a data-processing requirement in stages: keep the relevant items, transform them into the form you need, then produce a result such as a list, count, or sum. This is especially useful for backend tasks such as preparing response data, calculating in-memory summaries, or extracting values from a collection returned by another layer.

By the end of this lesson, you should be able to read and write a pipeline using filter, map, and a terminal aggregation operation without treating streams as mysterious syntax.


A stream is a processing pipeline, not a collection

A List<JobApplication> owns and stores application objects. Calling stream() does not copy that list into a new collection. Instead, it creates a Stream<JobApplication>: a pipeline through which the list’s elements can be processed.

A typical pipeline has three parts:

Pipeline part Typical operation Purpose
Source applications.stream() Supplies elements from a collection.
Intermediate operations filter, map, sorted Describe transformations; each returns another stream.
Terminal operation toList, count, sum Produces a non-stream result and starts processing.

The source collection remains separate from the result. A well-written stream pipeline does not mutate its source.

A Java Stream pipeline filters transaction objects, sorts the retained transactions, maps each transaction to its ID, and collects the resulting IDs into a list. Notice that the stream element type changes from `Transaction` to `Integer` at the `map` stage.

The image includes sorted() as an additional intermediate operation. For this lesson, focus on the central sequence:

  1. Filter the objects you want.
  2. Map each retained object into a useful value.
  3. Aggregate or collect the values into a final result.

Processing Data in Memory Using the Stream API - Dev.java

Read “Processing Data in Memory Using the Stream API” from Dev.java for the core map-filter-reduce model and the distinction between intermediate and terminal operations.

Start with the opening map-filter-reduce example and read the sales walkthrough. Identify which step selects objects, which extracts a property, and which produces one answer. Then read the sections “Optimizing the Map-Filter-Reduce Algorithm,” “Creating a Pipeline with Intermediate Operations,” and “Computing a Result with a Terminal Operation.” In particular, follow the pipeline explanation. Focus on two ideas: streams do not store their own data, and no elements are processed until a terminal operation is reached.


filter: retain elements that satisfy a rule

filter takes a Predicate<T>: a function that accepts one element and returns true or false.

  • true means the element continues through the pipeline.
  • false means that element is excluded.
  • The elements that remain have the same type as before.

Consider a compact application model:

import java.util.List;

enum ApplicationStatus {
    APPLIED,
    INTERVIEWING,
    REJECTED,
    OFFER
}

record JobApplication(
        String company,
        String role,
        ApplicationStatus status,
        int matchScore,
        int daysSinceApplied
) {
}

Suppose the tracker needs a list of applications where a follow-up may be appropriate: applications still in APPLIED status and at least seven days old.

List<JobApplication> followUpCandidates = applications.stream()
        .filter(application -> application.status() == ApplicationStatus.APPLIED)
        .filter(application -> application.daysSinceApplied() >= 7)
        .toList();

Both filter operations work on JobApplication values:

Stage Element type Effect
applications.stream() Stream<JobApplication> All applications enter the pipeline.
First filter Stream<JobApplication> Keeps only APPLIED applications.
Second filter Stream<JobApplication> Keeps only applications at least seven days old.
toList() List<JobApplication> Materializes the retained applications as a result.

For enum values, use ==, as shown above. For String content, use .equals() or a null-safe form such as:

.filter(application -> "Hyderabad".equals(application.company()))

A frequent mistake is to use == for strings. That compares object references, not reliably the textual content.

Prefer clear predicates over compressed logic

This is valid but harder to scan:

.filter(application ->
        application.status() == ApplicationStatus.APPLIED
                && application.daysSinceApplied() >= 7)

For a small rule, one combined predicate is fine. For a rule whose conditions have separate business meanings, two filters can read more naturally. The choice is about clarity, not a major performance difference.


map: transform every surviving element

While filter changes the number of elements, map changes what each element represents.

For example, the follow-up screen may only need company names:

List<String> companiesToFollowUp = applications.stream()
        .filter(application -> application.status() == ApplicationStatus.APPLIED)
        .filter(application -> application.daysSinceApplied() >= 7)
        .map(JobApplication::company)
        .toList();

The method reference:

JobApplication::company

means “given a JobApplication, call its company() accessor.” It is equivalent to:

application -> application.company()

Because JobApplication is a record, its accessor is named company(). With a conventional Java class containing getCompany(), the method reference would be:

JobApplication::getCompany

The important type transition occurs at map:

Stage Stream element type
Before map JobApplication
After map(JobApplication::company) String
After toList() List<String>

After the mapping stage, later operations receive strings, not applications. This is why the following is invalid:

.map(JobApplication::company)
.filter(application -> application.status() == ApplicationStatus.APPLIED)

After map, the lambda parameter is a String, so it no longer has a status() method. Put conditions that depend on application fields before mapping away from the application object.

You can map more than once when each transformation is meaningful:

List<String> normalizedCompanies = applications.stream()
        .filter(application -> application.status() == ApplicationStatus.INTERVIEWING)
        .map(JobApplication::company)
        .map(String::trim)
        .map(String::toUpperCase)
        .toList();

This does not alter the original company strings in applications. It produces a new result list containing transformed strings.

Java Streams API Explained (with examples)

Watch “Java Streams API Explained (with examples)” by Visual Computer Science for a visual demonstration of the type change between filter and map, followed by the crucial lazy-execution behavior.

Watch filtering to see a collection become a filtered list through a predicate and toList(). Continue with mapping types; focus on how extracting a property changes the stream element type. Then skip to lazy execution for the distinction between intermediate operations and the terminal operation that triggers processing.


Aggregate the pipeline into a useful answer

A terminal operation ends the pipeline. It consumes the stream and returns something that is not another stream.

There are several common kinds of result.

Collect into a list

toList() is appropriate when the requirement is “give me the matching values.”

List<String> interviewCompanies = applications.stream()
        .filter(application -> application.status() == ApplicationStatus.INTERVIEWING)
        .map(JobApplication::company)
        .toList();

For a List source, the encounter order is retained through ordinary filter and map operations. So the result follows the original list order unless you add an ordering operation such as sorted().

In modern Java, Stream.toList() returns an unmodifiable list. This is usually useful for a derived result:

interviewCompanies.add("New Company"); // UnsupportedOperationException

If a later step genuinely needs to change the result, build a mutable collection deliberately rather than assuming every returned list is mutable.

Count matching elements

When the UI needs a summary rather than the elements themselves, use count():

long activeApplicationCount = applications.stream()
        .filter(application ->
                application.status() == ApplicationStatus.APPLIED
                        || application.status() == ApplicationStatus.INTERVIEWING)
        .count();

The result is a long, not a collection. There is no reason to first create a list and then call .size() when the count is all you need.

Sum numeric values with a primitive stream

Suppose matchScore is a simple numeric score from 0 to 100 and you want the combined score of active applications.

int totalActiveMatchScore = applications.stream()
        .filter(application ->
                application.status() == ApplicationStatus.APPLIED
                        || application.status() == ApplicationStatus.INTERVIEWING)
        .mapToInt(JobApplication::matchScore)
        .sum();

mapToInt is a numeric version of map. It converts a Stream<JobApplication> into an IntStream, which has operations such as sum(), average(), min(), and max().

double averageActiveMatchScore = applications.stream()
        .filter(application ->
                application.status() == ApplicationStatus.APPLIED
                        || application.status() == ApplicationStatus.INTERVIEWING)
        .mapToInt(JobApplication::matchScore)
        .average()
        .orElse(0.0);

average() returns an OptionalDouble because an average is undefined when there are no matching elements. Here, orElse(0.0) defines a display-friendly fallback. In production code, choose a fallback that matches the business meaning; sometimes “no data” should remain visibly distinct from an actual average of zero.

Build a readable text summary

Aggregation can also mean joining many values into one string:

import static java.util.stream.Collectors.joining;

String companies = applications.stream()
        .filter(application -> application.status() == ApplicationStatus.INTERVIEWING)
        .map(JobApplication::company)
        .collect(joining(", "));

If the interview companies are "Acme" and "Orbit", companies becomes:

Acme, Orbit

Use this kind of output for logging, notifications, or simple display text. For API responses, structured JSON data is usually preferable to a formatted string.


The pipeline runs only at the terminal operation

Intermediate operations are lazy. This code describes a pipeline but does not yet process the list:

var highMatchApplications = applications.stream()
        .filter(application -> application.matchScore() >= 80)
        .map(JobApplication::company);

Processing begins only when a terminal operation is called:

List<String> companies = highMatchApplications.toList();

This design avoids unnecessary intermediate collections. Conceptually, Java can take one application from the source, test the filter, map it if it passes, and place the result into the final list.

It also means a stream is single-use:

var activeApplications = applications.stream()
        .filter(application -> application.status() == ApplicationStatus.APPLIED);

long count = activeApplications.count();

List<JobApplication> list = activeApplications.toList(); // Fails

The second terminal operation throws an IllegalStateException because count() has already consumed the stream.

When you need two results, create two pipelines from the collection:

long count = applications.stream()
        .filter(application -> application.status() == ApplicationStatus.APPLIED)
        .count();

List<String> companies = applications.stream()
        .filter(application -> application.status() == ApplicationStatus.APPLIED)
        .map(JobApplication::company)
        .toList();

For small, readable conditions, repeated pipelines are often clearer than trying to store and reuse a stream.


Pipeline order affects clarity and work performed

A good default ordering is:

  1. Filter out irrelevant objects early.
  2. Map only the values you need.
  3. Sort only if the result needs a particular order.
  4. Finish with the terminal operation that directly expresses the required result.

For example, if only a few applications are in the interview stage, filter before sorting:

List<String> sortedInterviewCompanies = applications.stream()
        .filter(application -> application.status() == ApplicationStatus.INTERVIEWING)
        .map(JobApplication::company)
        .sorted()
        .toList();

Sorting needs to consider all values that reach it. Filtering first can reduce the set that must be sorted, and it also reads like the business requirement: select interview applications, get their companies, then sort those company names.

Avoid using forEach to manually build a result outside the pipeline:

List<String> companies = new ArrayList<>();

applications.stream()
        .filter(application -> application.status() == ApplicationStatus.INTERVIEWING)
        .forEach(application -> companies.add(application.company()));

This works, but it hides the intended result in a mutable side effect. The direct version says more with less room for mistakes:

List<String> companies = applications.stream()
        .filter(application -> application.status() == ApplicationStatus.INTERVIEWING)
        .map(JobApplication::company)
        .toList();

Streams and SQL: similar questions, different layer

The underlying questions may look familiar from SQL:

Need Java Stream expression SQL concept
Keep matching rows/items filter(...) WHERE
Choose or derive fields map(...) SELECT expressions
Count/sum values count(), sum() COUNT, SUM
Order a result sorted(...) ORDER BY

The difference is where processing occurs.

Use a Java Stream when you already have an in-memory collection and need to derive values from it. For a Spring Boot application with PostgreSQL, filtering, sorting, pagination, and large aggregations over persisted job applications should usually happen in the database through a repository query. Fetching every row and filtering it in Java wastes memory and database capability.

For example, converting a small service-layer result into response-friendly strings is a reasonable Stream use. Retrieving every job application in the database just to count active ones is not.


Key takeaways

A Stream pipeline expresses processing over a data source without changing the source collection:

  • filter uses a boolean rule to keep selected elements; their type stays the same.
  • map transforms each surviving element, often changing the stream’s element type.
  • Terminal operations such as toList(), count(), sum(), and collect(...) trigger processing and produce the final result.
  • Use mapToInt, mapToLong, or mapToDouble when numeric aggregation is needed.
  • Streams are lazy and single-use. Create a new stream from the collection for each independent result.
  • Filter early, avoid mutation inside pipeline operations, and let the terminal operation express the result you actually need.

Next, you will focus on exception handling: preserving useful context when a Java operation fails, without swallowing the original failure or returning misleading results.

Can't find a good explanation? Sign up and we'll make it for you

Sign up