Table of Contents

Filtering in Streams

  • Last Updated: January 30, 2024
  • By: javahandson
  • Series
img

Filtering in Streams

Filtering in streams is the skill you reach for almost every time you touch the Stream API. You have a list of a thousand orders and you want the five cheapest ones from Mumbai, with no duplicates. A loop can do that. A stream pipeline says it in one readable line. This article walks through the five operations that shrink and select elements as they flow past: filter, distinct, limit, skip and peek. We will add takeWhile and dropWhile from Java 9 too, because they belong to the same family.

1. Introduction

Think about how you shop online. You open a category with four thousand products. Then you tick a price range, tick “in stock”, and jump to page three. You never look at four thousand things. You look at twenty, chosen by rules you set.

A stream pipeline works the same way. The source hands over elements one at a time, and the filtering operations decide which ones make it through to the other end. Some drop elements that fail a test. Others throw away duplicate copies. A third kind cuts the flow short after a fixed count.

These operations are all intermediate. They hand you a fresh stream instead of an answer, so you can chain as many as you like. The pipeline stays idle until a terminal operation such as collect or forEach pulls on it. If the pipeline model still feels fuzzy, our introduction to streams in Java covers the anatomy first.

1.1 What This Article Covers

  • filter – keep only the elements that pass a test.
  • distinct – throw away duplicate elements.
  • limit – stop after a fixed number of elements.
  • skip – ignore the first few elements.
  • peek – look at each element without changing it.
  • takeWhile and dropWhile – cut a run of elements at the first failure, added in Java 9.
  • How ordering inside a pipeline changes both the result and the cost.
  • What each operation does once you switch to a parallel stream.
  • Seven mistakes that trip up beginners, and the fix for each one.

2. The Filtering Family at a Glance

2.1 Five Operations, One Job

Every operation in this article answers one question: which elements continue? None of them change an element into something else. That job belongs to map, which our guide to mapping in Java 8 streams explains.

Picture a conveyor belt with inspectors standing along it. One inspector waves through only the red boxes. Another spots a box she has already seen and pushes it off the belt. A third counts to ten and then shuts the belt down. Nobody repaints a box.

That single idea makes the whole family easy to remember. Elements go in, fewer elements come out, and each one arrives unchanged.

2.2 Every One Returns a New Stream

Look at any of these method signatures and you will see Stream<T> as the return type. That matters for two reasons.

First, you can chain them. filter(...).distinct().limit(5) reads left to right like a sentence. Second, your original collection never moves. A stream reads from the source and writes nothing back to it.

New Java developers often expect list.stream().filter(...) to shrink list. It does not. You always capture the result in a new variable.

2.3 Nothing Runs Until the End

Here is the part that surprises people. Build a pipeline with three filters and print nothing, and Java runs zero of them. Intermediate operations only record your intent.

A terminal operation flips the switch. Then elements start flowing, and each one travels through the whole chain before the next one starts. We will prove this with peek in section 7, because seeing it beats reading about it.

2.4 A Quick Reference Table

OperationArgumentWhat it doesStops early?Since
filterPredicate<? super T>Keeps elements that pass the testNoJava 8
distinctnoneRemoves duplicates using equalsNoJava 8
limitlong maxSizeKeeps at most that many elementsYesJava 8
skiplong nDrops the first n elementsNoJava 8
peekConsumer<? super T>Runs an action, passes the element onNoJava 8
takeWhilePredicate<? super T>Keeps the leading run that passesYesJava 9
dropWhilePredicate<? super T>Drops the leading run that passesNoJava 9

3. The filter Method

3.1 Syntax and Return Type

Stream<T> filter(Predicate<? super T> predicate)

// Argument : a Predicate, which takes one element and answers true or false
// Returns  : a new Stream holding only the elements that answered true

A Predicate is a functional interface with one method, test, that returns a boolean. You almost always pass a lambda. Our article on predefined functional interfaces digs into the whole family.

Read filter as “keep if true”. That phrasing prevents the single most common mix-up, which we cover in section 3.6.

3.2 Your First filter

Let us pull the even numbers out of a list.

package com.javahandson.filtering;

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class FilterBasics {
    public static void main(String[] args) {

        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);

        List<Integer> evenNumbers = numbers.stream()
                .filter(n -> n % 2 == 0)
                .collect(Collectors.toList());

        System.out.println("Even numbers : " + evenNumbers); // Output: Even numbers : [2, 4, 6]
        System.out.println("Original     : " + numbers);     // Output: Original     : [1, 2, 3, 4, 5, 6]
    }
}

Notice the second print. The original list still holds all six numbers. Filtering built a new list and left the source alone.

On Java 16 and later you can swap collect(Collectors.toList()) for the shorter toList(). The one difference worth knowing: toList() hands back an unmodifiable list, so a later add throws UnsupportedOperationException.

3.3 Pulling the Predicate Out

Inline lambdas read beautifully until the condition grows. When the test needs a name or a second home, store it in a variable.

package com.javahandson.filtering;

import java.util.Arrays;
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.Collectors;

public class NamedPredicate {
    public static void main(String[] args) {

        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

        Predicate<Integer> isEven = n -> n % 2 == 0;
        Predicate<Integer> isBig  = n -> n > 5;

        List<Integer> result = numbers.stream()
                .filter(isEven.and(isBig))
                .collect(Collectors.toList());

        System.out.println(result); // Output: [6, 8, 10]
    }
}

A named predicate documents itself. filter(isEven) tells a reader what happens without any comment.

That and call joins two predicates into one. Predicates also offer or and negate, and our guide to function composition in Java 8 covers all three properly.

3.4 Filtering Objects, Not Just Numbers

Real code filters objects far more often than integers. Here is a small Employee class we will reuse.

package com.javahandson.filtering;

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

class Employee {
    private final String name;
    private final String city;
    private final int salary;

    Employee(String name, String city, int salary) {
        this.name = name;
        this.city = city;
        this.salary = salary;
    }

    public String getName()  { return name; }
    public String getCity()  { return city; }
    public int getSalary()   { return salary; }

    @Override
    public String toString() { return name + "(" + city + ", " + salary + ")"; }
}

public class FilterObjects {
    public static void main(String[] args) {

        List<Employee> staff = Arrays.asList(
                new Employee("Asha",   "Pune",   90000),
                new Employee("Rahul",  "Mumbai", 65000),
                new Employee("Meera",  "Pune",   120000),
                new Employee("Vikram", "Delhi",  75000));

        List<String> richPuneStaff = staff.stream()
                .filter(e -> e.getCity().equals("Pune"))
                .filter(e -> e.getSalary() > 80000)
                .map(Employee::getName)
                .collect(Collectors.toList());

        System.out.println(richPuneStaff); // Output: [Asha, Meera]
    }
}

Two filters run back to back here. Each element meets the first test, and only survivors reach the second.

The Employee::getName part is a method reference, a shorthand for e -> e.getName(). Our post on method reference in Java 8 explains all four forms.

3.5 Two filters or One Big Condition?

You could write that same logic in a single filter with &&. Which version wins?

  • Two filters read better. Each line states one rule, so a reviewer scans them quickly.
  • One filter saves a tiny amount of plumbing, because the pipeline holds one stage instead of two.
  • Speed rarely differs. Both versions short-circuit on the first failing test, so neither does extra work per element.
  • Reuse favours two. Separate rules become named predicates you can share across pipelines.

Pick readability. Split the rules when they express different ideas, and merge them when they express one idea awkwardly cut in half.

3.6 filter Keeps, It Never Deletes

Ask a beginner to remove the even numbers and you often get this:

// Wrong: this KEEPS the even numbers instead of removing them
List<Integer> odd = numbers.stream()
        .filter(n -> n % 2 == 0)
        .collect(Collectors.toList());

// Right: flip the condition
List<Integer> oddOnly = numbers.stream()
        .filter(n -> n % 2 != 0)
        .collect(Collectors.toList());

// Also right: negate a named predicate
Predicate<Integer> isEven = n -> n % 2 == 0;
List<Integer> alsoOdd = numbers.stream()
        .filter(isEven.negate())
        .collect(Collectors.toList());

The word “filter” trips people up because a coffee filter traps what you want to lose. In Java the predicate describes what you want to keep. Say “keep if true” in your head every time and the confusion disappears.

4. The distinct Method

4.1 Syntax and Return Type

Stream<T> distinct()

// Argument : none
// Returns  : a new Stream with duplicates removed, compared using equals()

No arguments, no lambda, nothing to configure. That simplicity hides a catch, and section 4.3 explains it.

4.2 Dropping Duplicate Numbers

package com.javahandson.filtering;

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class DistinctBasics {
    public static void main(String[] args) {

        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 2, 4, 3, 6);

        List<Integer> unique = numbers.stream()
                .distinct()
                .collect(Collectors.toList());

        System.out.println("Unique : " + unique); // Output: Unique : [1, 2, 3, 4, 5, 6]

        List<Integer> uniqueEven = numbers.stream()
                .filter(n -> n % 2 == 0)
                .distinct()
                .collect(Collectors.toList());

        System.out.println("Unique even : " + uniqueEven); // Output: Unique even : [2, 4, 6]
    }
}

Strings behave the same way, because String already implements equals sensibly. Wrapper types such as Integer do too.

4.3 distinct Leans on equals and hashCode

The contract says distinct compares elements with equals. Under the hood, a sequential stream keeps a hash set of what it has already seen, so hashCode matters just as much in practice.

Your own classes inherit both methods from Object unless you override them. That default equals compares memory identity, so two objects with identical field values still count as different.

package com.javahandson.filtering;

import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

class Book {
    private final String title;
    private final String author;

    Book(String title, String author) {
        this.title = title;
        this.author = author;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Book other = (Book) o;
        return title.equals(other.title) && author.equals(other.author);
    }

    @Override
    public int hashCode() {
        return Objects.hash(title, author);
    }

    @Override
    public String toString() { return title; }
}

public class DistinctObjects {
    public static void main(String[] args) {

        List<Book> books = Arrays.asList(
                new Book("Effective Java", "Bloch"),
                new Book("Clean Code", "Martin"),
                new Book("Effective Java", "Bloch"));

        List<Book> unique = books.stream()
                .distinct()
                .collect(Collectors.toList());

        System.out.println(unique); // Output: [Effective Java, Clean Code]
    }
}

Delete those two overrides and the output becomes all three books. Nothing throws, nothing warns you. The wrong answer simply appears, which makes this a nasty bug to chase.

Our article on the Object class in Java covers the equals and hashCode contract in detail.

4.4 Which Copy Survives?

Suppose two employees compare as equal but carry different salaries. Which one lands in your result?

For an ordered stream the rule is clear and stable: the element that appeared first wins. A list gives you an ordered stream, so distinct behaves predictably there.

For an unordered source such as a HashSet, Java makes no such promise. Any of the equal copies may survive. Sort or collect first when the choice actually matters to you.

4.5 A Record Makes distinct Easy

Java 16 gave us records, and a record writes equals, hashCode and toString for you from its components.

record Book(String title, String author) { }

// distinct() now works correctly with zero extra code
List<Book> unique = books.stream()
        .distinct()
        .collect(Collectors.toList());

Twenty lines of boilerplate collapse into one. Reach for a record whenever a class exists purely to carry values, and distinct stops being a trap.

5. The limit Method

5.1 Syntax and Return Type

Stream<T> limit(long maxSize)

// Argument : how many elements you want at most, never negative
// Returns  : a new Stream holding at most maxSize elements

Note the phrase “at most”. Ask for ten from a stream of four and you get four, with no error.

5.2 Taking the First Few

package com.javahandson.filtering;

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class LimitBasics {
    public static void main(String[] args) {

        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8);

        List<Integer> firstThree = numbers.stream()
                .limit(3)
                .collect(Collectors.toList());

        System.out.println(firstThree); // Output: [1, 2, 3]

        List<Integer> firstThreeEven = numbers.stream()
                .filter(n -> n % 2 == 0)
                .limit(3)
                .collect(Collectors.toList());

        System.out.println(firstThreeEven); // Output: [2, 4, 6]

        List<Integer> askedForTwenty = numbers.stream()
                .limit(20)
                .collect(Collectors.toList());

        System.out.println(askedForTwenty); // Output: [1, 2, 3, 4, 5, 6, 7, 8]
    }
}

5.3 limit Stops the Pipeline Early

Most intermediate operations examine every element. limit does not, and that makes it special.

Once enough elements pass through, limit tells the source to stop producing. Java calls this short-circuiting. In the firstThreeEven example above, the pipeline touches numbers 1 through 6 and never looks at 7 or 8.

On a list of eight that saves nothing. On a database-backed stream of a million rows, it saves almost everything.

5.4 Taming an Endless Stream

Stream.iterate and Stream.generate produce elements forever. Without limit, your program hangs.

package com.javahandson.filtering;

import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class InfiniteStreams {
    public static void main(String[] args) {

        // First 5 multiples of 3, starting at 3
        List<Integer> multiples = Stream.iterate(3, n -> n + 3)
                .limit(5)
                .collect(Collectors.toList());

        System.out.println(multiples); // Output: [3, 6, 9, 12, 15]

        // First 4 numbers divisible by 7 above 100
        List<Integer> sevens = Stream.iterate(101, n -> n + 1)
                .filter(n -> n % 7 == 0)
                .limit(4)
                .collect(Collectors.toList());

        System.out.println(sevens); // Output: [105, 112, 119, 126]
    }
}

Watch the order in that second pipeline. Put limit(4) before the filter and you take the first four numbers from 101, then keep whichever divide by seven. The answer changes completely.

6. The skip Method

6.1 Syntax and Return Type

Stream<T> skip(long n)

// Argument : how many leading elements to throw away, never negative
// Returns  : a new Stream without those first n elements

skip is the mirror image of limit. One keeps the front of the stream, the other throws it away.

6.2 Dropping the First Few

package com.javahandson.filtering;

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class SkipBasics {
    public static void main(String[] args) {

        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

        List<Integer> afterThree = numbers.stream()
                .skip(3)
                .collect(Collectors.toList());

        System.out.println(afterThree); // Output: [4, 5, 6, 7, 8, 9, 10]

        List<Integer> evenAfterThree = numbers.stream()
                .filter(n -> n % 2 == 0)
                .skip(3)
                .collect(Collectors.toList());

        System.out.println(evenAfterThree); // Output: [8, 10]
    }
}

In the second pipeline the filter runs first, producing 2, 4, 6, 8 and 10. Then skip(3) drops 2, 4 and 6.

6.3 skip Plus limit Gives You Paging

Put the two together and you get page navigation, the pattern behind almost every search results screen.

package com.javahandson.filtering;

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class Paging {

    static <T> List<T> page(List<T> source, int pageNumber, int pageSize) {
        return source.stream()
                .skip((long) (pageNumber - 1) * pageSize)
                .limit(pageSize)
                .collect(Collectors.toList());
    }

    public static void main(String[] args) {

        List<String> cities = Arrays.asList(
                "Pune", "Mumbai", "Delhi", "Chennai",
                "Kolkata", "Jaipur", "Kochi");

        System.out.println(page(cities, 1, 3)); // Output: [Pune, Mumbai, Delhi]
        System.out.println(page(cities, 2, 3)); // Output: [Chennai, Kolkata, Jaipur]
        System.out.println(page(cities, 3, 3)); // Output: [Kochi]
    }
}

The cast to long guards against integer overflow on a very large page number. Small detail, easy to forget, and it costs nothing.

One honest warning. This pattern reads well for a list already in memory, but never use it against a database. Let SQL do the paging with LIMIT and OFFSET instead of loading a million rows to throw most of them away.

6.4 Skipping More Than You Have

Ask to skip fifty elements from a stream of seven and Java hands back an empty stream. No exception, no warning.

That silence causes real confusion during debugging. An empty result from a paging method usually means the page number ran past the end of the data.

7. The peek Method

7.1 Syntax and Return Type

Stream<T> peek(Consumer<? super T> action)

// Argument : a Consumer, which accepts one element and returns nothing
// Returns  : the same elements, untouched, passed straight through

peek is the odd one out. It removes nothing at all. Every element that goes in also comes out, so the count never changes.

Why does it exist then? The Java documentation answers plainly: peek exists mainly to support debugging.

7.2 Watching Elements Go Past

package com.javahandson.filtering;

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class PeekBasics {
    public static void main(String[] args) {

        List<String> names = Arrays.asList("asha", "rahul", "meera", "vikram");

        List<String> result = names.stream()
                .peek(n -> System.out.println("source  : " + n))
                .filter(n -> n.length() > 4)
                .peek(n -> System.out.println("  passed: " + n))
                .map(String::toUpperCase)
                .collect(Collectors.toList());

        System.out.println(result);
    }
}
/* Output:
source  : asha
source  : rahul
  passed: rahul
source  : meera
  passed: meera
source  : vikram
  passed: vikram
[RAHUL, MEERA, VIKRAM]
*/

Drop a peek before and after a stage and you see exactly which elements that stage removed. No debugger, no breakpoints, no restructuring of the pipeline.

7.3 peek Proves Streams Are Lazy

Look at that output again. The lines interleave: source, source, passed, source, passed. They do not group.

Each element travels the whole pipeline before the next one starts. Java never builds an intermediate list of all four names. That behaviour has a name, vertical execution, and it explains why a stream can handle an infinite source without running out of memory.

Now try removing collect from the end of that program. The output goes completely blank, because a pipeline with no terminal operation runs nothing.

7.4 Keep Real Work Out of peek

Sooner or later someone spots that peek can mutate objects and starts doing this:

// Do not do this
List<Employee> updated = staff.stream()
        .peek(e -> e.setSalary(e.getSalary() * 2))   // hidden side effect
        .collect(Collectors.toList());

// Do this instead: map to a new object
List<Employee> better = staff.stream()
        .map(e -> new Employee(e.getName(), e.getCity(), e.getSalary() * 2))
        .collect(Collectors.toList());

Three problems come with the first version. It hides a mutation inside an operation named “peek”, which misleads every future reader. It changes shared objects that other code may hold. And on a parallel stream it introduces a data race.

Use peek for logging and printing. Use map when you want a different value.

7.5 The count Surprise

Here is a genuinely surprising one that catches experienced developers.

List<String> names = Arrays.asList("asha", "rahul", "meera");

long a = names.stream()
        .peek(n -> System.out.println("seen " + n))
        .count();
// Output on Java 9 and later: nothing at all, then a == 3

long b = names.stream()
        .filter(n -> n.length() > 4)
        .peek(n -> System.out.println("seen " + n))
        .count();
// Output: seen rahul / seen meera, then b == 2

Since Java 9, count may skip the pipeline entirely when it can work out the answer from the source alone. An ArrayList knows its own size, and peek cannot change that size, so nothing runs.

Adding a filter breaks the shortcut, because now the count depends on the data. This is exactly why the documentation warns against side effects in peek: they may never happen.

8. takeWhile and dropWhile

8.1 takeWhile Stops at the First Miss

Java 9 added two more filtering operations. Both take a predicate, and both care about position rather than the whole stream.

package com.javahandson.filtering;

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class TakeAndDrop {
    public static void main(String[] args) {

        List<Integer> numbers = Arrays.asList(2, 4, 6, 7, 8, 10);

        List<Integer> taken = numbers.stream()
                .takeWhile(n -> n % 2 == 0)
                .collect(Collectors.toList());

        System.out.println("takeWhile : " + taken); // Output: takeWhile : [2, 4, 6]

        List<Integer> dropped = numbers.stream()
                .dropWhile(n -> n % 2 == 0)
                .collect(Collectors.toList());

        System.out.println("dropWhile : " + dropped); // Output: dropWhile : [7, 8, 10]
    }
}

takeWhile collects elements from the front while the test passes. The moment 7 fails, it stops and ignores everything after, including the even numbers 8 and 10.

8.2 dropWhile Waits for the First Miss

dropWhile throws away that same leading run and keeps the rest. Once an element fails the test, every later element rides through untested.

Together the two operations split a stream at exactly one point. Concatenate their results and you rebuild the original list.

8.3 takeWhile Against filter

QuestionfiltertakeWhile
How many elements does it test?All of themOnly up to the first failure
Result on 2, 4, 6, 7, 8, 10 with “is even”2, 4, 6, 8, 102, 4, 6
Does position matter?NoYes
Does it short-circuit?NoYes
Java version89

Read the second row carefully. On unsorted data the two give different answers, and filter is what you usually want.

8.4 Sort First, Then Take

takeWhile earns its keep on sorted data. Sort employees by salary, then take everyone under a threshold, and the pipeline stops at the first person above it.

List<String> junior = staff.stream()
        .sorted(Comparator.comparingInt(Employee::getSalary))
        .takeWhile(e -> e.getSalary() < 80000)
        .map(Employee::getName)
        .collect(Collectors.toList());

System.out.println(junior); // Output: [Rahul, Vikram]

On an unsorted list that same pipeline would stop at the wrong place. Sorting first is what makes the position-based rule meaningful.

9. Order Matters in a Pipeline

9.1 Put filter Before map

Both of these pipelines print the same answer. One does far less work.

// Slower: converts all 1000 names, then throws most away
list.stream()
    .map(Employee::getName)
    .filter(name -> name.startsWith("A"))
    .collect(Collectors.toList());

// Faster: tests 1000 cheaply, converts only the survivors
list.stream()
    .filter(e -> e.getName().startsWith("A"))
    .map(Employee::getName)
    .collect(Collectors.toList());

The rule generalises nicely. Shrink the stream as early as you can, so every later stage handles fewer elements.

This matters most when map does something expensive, such as building an object or calling a service.

9.2 Put filter Before sorted

Sorting costs far more than testing. Sorting a thousand elements to keep ten wastes almost all of that effort.

Filter first, then sort the handful that remain. On large inputs this single reordering often gives the biggest speed win in a whole pipeline.

The same logic applies to distinct, which also has to remember what it has seen.

9.3 distinct Before or After filter

These two produce identical results, so choose on cost alone:

  • .distinct().filter(...) builds a hash set from every element, then tests the survivors.
  • .filter(...).distinct() tests every element cheaply, then hashes only the ones that passed.
  • The second version usually wins, because hashing costs more than a simple comparison.
  • Swap the order only when the predicate itself runs slowly, such as a regular expression or a network call.

9.4 A Side by Side Table

PipelineResultWhy
.filter(even).limit(3)First 3 even numbersFilter runs first, limit counts survivors
.limit(3).filter(even)Even numbers among the first 3Limit runs first, filter tests only those 3
.skip(2).limit(3)Elements 3, 4 and 5Drop 2, then take 3
.limit(3).skip(2)Element 3 onlyTake 3, then drop 2 of them
.distinct().limit(3)First 3 unique elementsDuplicates removed before counting
.limit(3).distinct()Unique among the first 3Could return fewer than 3

Interviewers love the middle rows. They look almost identical and behave completely differently.

10. Filtering Inside a Parallel Stream

10.1 filter Splits Cleanly

Switch to parallelStream() and filter keeps working exactly as before. Each thread tests its own chunk, and no thread needs to know what the others found.

That independence makes filter a great fit for parallel work, as long as your predicate stays free of shared state.

10.2 distinct, limit and skip Cost More

The other three depend on position or on history, and that dependency costs real time in parallel:

  • distinct must share what every thread has seen so far, which forces coordination between them.
  • limit on an ordered stream must know which elements come first, so threads cannot simply stop early.
  • skip has the same problem in reverse, because “the first three” only means something in order.
  • peek runs on whichever thread handles the element, so your log lines arrive jumbled.

A parallel pipeline that ends in limit(10) often runs slower than the plain sequential version. Measure before you assume otherwise.

10.3 The unordered Escape Hatch

When you genuinely do not care which ten elements you get, say so:

List<Integer> any10 = numbers.parallelStream()
        .unordered()
        .filter(n -> n % 2 == 0)
        .limit(10)
        .collect(Collectors.toList());

unordered releases the encounter-order promise, which frees limit and distinct to take whatever arrives first. You trade predictability for speed, so use it only when the order truly does not matter.

Our guide to the parallel stream in Java 8 covers threading and the fork-join pool behind all of this.

11. Common Mistakes and Pitfalls

11.1 Expecting the Original List to Change

// Wrong: the result goes nowhere
numbers.stream().filter(n -> n > 5);
System.out.println(numbers); // still the full list

// Right: capture the new list
List<Integer> big = numbers.stream()
        .filter(n -> n > 5)
        .collect(Collectors.toList());

Streams never write back to their source. Always assign the result to something.

11.2 Forgetting the Terminal Operation

The line above has a second flaw. Without collect, forEach or another terminal call, the predicate never even runs.

Your compiler stays quiet and your program produces nothing. When a stream seems to do nothing at all, check the last line of the chain first.

11.3 Reusing One Stream Twice

Stream<Integer> s = numbers.stream().filter(n -> n > 2);

List<Integer> first  = s.collect(Collectors.toList());  // fine
List<Integer> second = s.collect(Collectors.toList());  // IllegalStateException:
                                                        // stream has already been
                                                        // operated upon or closed

One stream, one terminal operation. Call numbers.stream() again to build a fresh pipeline.

11.4 distinct on a Class Without equals

We covered this in section 4.3, and it deserves the repeat because the failure stays silent. Duplicates survive, no exception fires, and your report shows the wrong totals.

Override equals and hashCode together, or make the class a record.

11.5 A Negative Argument to limit or skip

numbers.stream().limit(-1);  // IllegalArgumentException
numbers.stream().skip(-5);   // IllegalArgumentException
numbers.stream().limit(0);   // fine: an empty stream

Zero works and returns nothing. Anything below zero throws immediately, which usually means a page-number calculation went wrong somewhere upstream.

11.6 Real Work Inside peek

Saving to a database inside peek looks clever and behaves badly. Section 7.5 showed that count may skip the whole pipeline, so those saves may never happen.

Put real work in a terminal operation such as forEach, where the contract guarantees it runs.

11.7 skip and limit in the Wrong Order

For paging, skip always comes before limit. Reverse them and you take a small window first, then chop it down further.

Page 3 of size 10 needs .skip(20).limit(10). Writing .limit(10).skip(20) returns an empty list every single time, which looks exactly like a data problem.

12. A Practical Walkthrough

12.1 The Data

Let us tie everything together with one small program. We have a list of orders, and a few of them arrived twice because a client retried a flaky request.

12.2 The Requirement

  • Ignore cancelled orders completely.
  • Remove duplicate entries, where the same id means the same order.
  • Keep only orders worth more than 500.
  • Sort them from the most expensive down.
  • Show the top three, and log what survived each stage.

12.3 Building the Pipeline

package com.javahandson.filtering;

import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

class Order {
    private final int id;
    private final String customer;
    private final double amount;
    private final String status;

    Order(int id, String customer, double amount, String status) {
        this.id = id;
        this.customer = customer;
        this.amount = amount;
        this.status = status;
    }

    public int getId()          { return id; }
    public String getCustomer() { return customer; }
    public double getAmount()   { return amount; }
    public String getStatus()   { return status; }

    // Two orders are the same order when their ids match
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        return id == ((Order) o).id;
    }

    @Override
    public int hashCode() { return Objects.hash(id); }

    @Override
    public String toString() {
        return "#" + id + " " + customer + " " + amount;
    }
}

public class TopOrdersReport {
    public static void main(String[] args) {

        List<Order> orders = Arrays.asList(
                new Order(1, "Asha",   1200.0, "PAID"),
                new Order(2, "Rahul",   450.0, "PAID"),
                new Order(3, "Meera",   980.0, "PAID"),
                new Order(1, "Asha",   1200.0, "PAID"),   // duplicate of #1
                new Order(4, "Vikram", 2500.0, "CANCELLED"),
                new Order(5, "Neha",    760.0, "PAID"),
                new Order(3, "Meera",   980.0, "PAID"),   // duplicate of #3
                new Order(6, "Imran",   640.0, "PAID"));

        List<Order> topThree = orders.stream()
                .filter(o -> !o.getStatus().equals("CANCELLED"))
                .peek(o -> System.out.println("active   : " + o))
                .distinct()
                .peek(o -> System.out.println("  unique : " + o))
                .filter(o -> o.getAmount() > 500)
                .sorted(Comparator.comparingDouble(Order::getAmount).reversed())
                .limit(3)
                .collect(Collectors.toList());

        System.out.println("\nTop three orders : " + topThree);
    }
}

12.4 Reading the Output

active   : #1 Asha 1200.0
  unique : #1 Asha 1200.0
active   : #2 Rahul 450.0
  unique : #2 Rahul 450.0
active   : #3 Meera 980.0
  unique : #3 Meera 980.0
active   : #1 Asha 1200.0
active   : #5 Neha 760.0
  unique : #5 Neha 760.0
active   : #3 Meera 980.0
active   : #6 Imran 640.0
  unique : #6 Imran 640.0

Top three orders : [#1 Asha 1200.0, #3 Meera 980.0, #5 Neha 760.0]

Three details in that log repay a close look.

  • Vikram’s cancelled order never appears, because the first filter removed it before the first peek.
  • The repeat entries for #1 and #3 print “active” but never print “unique”, which shows distinct doing its job.
  • Rahul at 450 passes both peeks, then the second filter drops him. Placement of a peek decides what you can see.

Note that sorted holds everything until the last element arrives, so it prints nothing itself. Adding limit(3) after a sort still helps, because it trims the result rather than the sorting work.

13. Interview Questions

These come up constantly in Java interviews. Short, concrete answers land best.

Q: What is filtering in streams in Java?

A: Filtering in streams means selecting which elements continue through a pipeline. The Stream API gives you filter for a condition, distinct for duplicates, limit and skip for position, and peek for observing elements. Java 9 added takeWhile and dropWhile. All of them return a new stream and leave your source collection untouched.

Q: Does filter modify the original collection?

A: No. A stream reads from the source and never writes back to it. You have to capture the result with collect or another terminal operation. Printing the original list after a filter shows every element still in place.

Q: What is the difference between limit and skip?

A: They are opposites. limit(3) keeps the first three elements and drops the rest, while skip(3) drops the first three and keeps the rest. Put skip before limit to build a page of results. Both throw IllegalArgumentException on a negative argument.

Q: How does distinct decide that two objects are duplicates?

A: It compares elements with equals, and sequential implementations track what they have seen in a hash set, so hashCode matters too. A class that inherits both methods from Object compares by identity, so distinct keeps every object. Override equals and hashCode together, or use a record.

Q: What is peek used for, and why should I avoid side effects in it?

A: peek exists mainly for debugging. It runs an action on each element and passes that element straight through. Avoid side effects because Java may skip the action entirely, for example when count computes the answer from the source size, or when a short-circuiting operation ends the pipeline early.

Q: What is the difference between filter and takeWhile?

A: filter tests every element and keeps each one that passes. takeWhile keeps only the leading run of elements that pass and stops at the first failure. On the list 2, 4, 6, 7, 8, 10 with an “is even” test, filter returns 2, 4, 6, 8, 10 while takeWhile returns 2, 4, 6.

Q: Which filtering operations short-circuit?

A: limit and takeWhile short-circuit, so they can end a pipeline before the source runs out. That property lets limit tame an infinite stream from Stream.iterate or Stream.generate. filter, distinct, skip and peek all examine every element that reaches them.

Q: Should filter come before or after map?

A: Put filter first whenever both orders give the same answer. Filtering early shrinks the stream, so map, sorted and distinct each handle fewer elements. The gain grows when map does expensive work such as creating objects or calling a service.

Q: Why do I get IllegalStateException from my stream?

A: You called a second terminal operation on a stream that already ran. Each stream supports exactly one traversal. Call stream() on the collection again to build a fresh pipeline, or store the result in a list and reuse that list.

Q: Do distinct and limit behave differently on a parallel stream?

A: Yes, and both cost more. distinct has to share seen elements across threads, and limit on an ordered stream has to respect encounter order before any thread can stop. Calling unordered releases that order guarantee and often speeds both up, so use it only when the order does not matter.

14. Conclusion

Let us wrap up what we covered. Filtering in streams comes down to a small set of operations that decide which elements continue down the pipeline.

  • filter keeps elements that pass a predicate. Read it as “keep if true”, never as “remove if true”.
  • distinct removes duplicates using equals, so your own classes need equals and hashCode, or need to be records.
  • limit caps the element count and short-circuits, which makes infinite streams safe to use.
  • skip drops leading elements, and pairs with limit to build pages of results.
  • peek shows you what flows past. Keep it for logging, because Java may skip it altogether.
  • takeWhile and dropWhile from Java 9 split a stream at the first element that fails the test.

Two habits carry most of the value here. Filter as early as you can, so later stages handle less data. And remember that a pipeline sits still until a terminal operation asks it for an answer.

Try the walkthrough program yourself, then move the peek calls around. Watching the log change teaches the laziness rule faster than any explanation.

Further Reading

Leave a Comment