Java Stream Filter with Multiple Conditions: 3 Ways

Java stream filter with multiple conditions using && and ||, chained filter() calls and Predicate and(), or(), negate(), plus dynamic search filters.

Diagram showing three equivalent ways to filter rooms by availability, beds and price: one lambda with &&, chained filter() calls and Predicate.and()

To filter a Java stream on multiple conditions, we join the conditions with && and || inside one filter() call or chain several filter() calls, and an element stays only when it passes every filter. For conditions that we reuse or build at runtime, we store each one as a Predicate and combine them with and(), or() and negate().

We need multiple conditions in search screens, reports and validation, for example to find available rooms with two beds under a price limit, or orders that are paid but not shipped. The following example applies the same two conditions, greater than 5 and even, in three equivalent ways and adds one OR condition.

List<Integer> nums = List.of(3, 8, 12, 15, 20);
Predicate<Integer> big = n -> n > 5;
Predicate<Integer> even = n -> n % 2 == 0;

List<Integer> oneLambda = nums.stream().filter(n -> n > 5 && n % 2 == 0).toList();       // [8, 12, 20]
List<Integer> chained = nums.stream().filter(big).filter(even).toList();                 // [8, 12, 20]
List<Integer> combined = nums.stream().filter(big.and(even)).toList();                   // [8, 12, 20]
List<Integer> smallOr20 = nums.stream().filter(big.negate().or(n -> n == 20)).toList();  // [3, 20]

All three AND forms keep the same elements, so the choice between them is about readability, reuse and OR support. We compare the forms, look at the order of the conditions and at null-safe checks, and build a hotel room search that combines only the criteria the user filled in.

1. Three Ways to Combine Conditions in filter()

The Stream filter() method accepts one Predicate, which is a function that returns true for the elements to keep. Multiple conditions therefore always end up as one predicate per filter() call, either written as one lambda or composed from smaller predicates.

Diagram showing three equivalent ways to filter rooms by availability, beds and price: one lambda with &&, chained filter() calls and Predicate.and()
One lambda with &&, chained filter() calls and Predicate.and() keep the same rooms; only the named predicates can also be reused and combined with or()

The examples in this article use a Room record from a hotel booking app.

record Room(int number, int beds, int price, boolean available, String view) {}

1.1. Using && and || in One Lambda

A single lambda expression with the conditional operators && and || is the shortest form and supports any mix of AND and OR. The operator && binds more tightly than ||, so we add parentheses whenever both appear, or the condition means something else.

List<Room> rooms = List.of(
        new Room(101, 1, 80, true, "garden"),
        new Room(102, 2, 120, true, "sea"),
        new Room(201, 2, 150, false, "sea"),
        new Room(202, 3, 200, true, "city"),
        new Room(301, 3, 180, false, "sea"));

List<Integer> twoBedsCheap = rooms.stream().filter(r -> r.available() && r.beds() >= 2 && r.price() <= 150).map(Room::number).toList();   // [102]
List<Integer> withParens = rooms.stream().filter(r -> r.available() && (r.view().equals("sea") || r.beds() == 3)).map(Room::number).toList();   // [102, 202]
List<Integer> noParens = rooms.stream().filter(r -> r.available() && r.view().equals("sea") || r.beds() == 3).map(Room::number).toList();      // [102, 202, 301]

Without parentheses, the condition reads as “available sea view rooms, or any room with three beds”, so the booked room 301 passes the last filter. We always write the parentheses that express what we mean.

1.2. Chaining Multiple filter() Calls

Each filter() call removes the elements that fail its condition, so a chain of filters is an AND of all conditions. The chain reads like a checklist, one condition per line, which helps when the conditions are long. It cannot express OR, because an element removed by one filter never reaches the next one.

List<Room> rooms = List.of(new Room(101, 1, 80, true, "garden"), new Room(102, 2, 120, true, "sea"), new Room(201, 2, 150, false, "sea"));

List<Integer> matches = rooms.stream()
        .filter(Room::available)
        .filter(r -> r.beds() >= 2)
        .filter(r -> r.price() <= 150)
        .map(Room::number)
        .toList();                              // [102]

1.3. Combining Named Predicates with and(), or() and negate()

A Predicate variable gives a condition a name that we can reuse in several pipelines and in unit tests. The default methods and(), or() and negate() build a new predicate from existing ones, and the static Predicate.not(), added in Java 11, negates a method reference or lambda in place.

Predicate<Room> free = Room::available;
Predicate<Room> family = r -> r.beds() >= 3;
Predicate<Room> seaView = r -> r.view().equals("sea");
List<Room> rooms = List.of(new Room(101, 1, 80, true, "garden"), new Room(102, 2, 120, true, "sea"), new Room(201, 2, 150, false, "sea"), new Room(202, 3, 200, true, "city"));

List<Integer> freeFamilyOrSea = rooms.stream().filter(free.and(family.or(seaView))).map(Room::number).toList();   // [102, 202]
List<Integer> freeNotSea = rooms.stream().filter(free.and(Predicate.not(seaView))).map(Room::number).toList();    // [101, 202]
List<Integer> booked = rooms.stream().filter(free.negate()).map(Room::number).toList();                           // [201]

The nesting of the method calls replaces the parentheses of the lambda form, so free.and(family.or(seaView)) reads as “free, and either family or sea view”. The full Predicate API, including isEqual() and primitive predicates, is in the Java Predicate guide.

2. One filter() or Several?

Use one lambda for short conditions that belong together, chained filters for long AND conditions, and named predicates whenever a condition is reused, tested on its own or chosen at runtime. The table compares the three forms on the points that come up in code reviews.

AspectOne lambda with && and ||Chained filter() callsPredicate with and(), or()
ANDYesYesYes
OR and NOTYesNoYes, or(), negate(), not()
Reuse in other pipelinesNoNoYes
Built at runtimeNoPartly, with if around the streamYes
Stream stages added11 per condition1

Each extra filter() call adds a pipeline stage and one more method call per element, while a combined predicate also adds one call per and(). For most collections, that difference is far smaller than the cost of the conditions themselves, so we pick the most readable form and measure with a profiler only when a pipeline shows up as slow.

3. Order of Conditions and Short-Circuit Evaluation

The operator && evaluates its right side only when the left side is true, and || only when the left side is false. The Predicate.and() and or() methods follow the same rule, and a chain of filters stops testing an element at the first filter it fails. So the order of conditions changes how often each check runs.

In the following example, a price check is cheap, while a call to a pricing service that checks for a discount is expensive. A counter shows how often the expensive check runs in each order.

AtomicInteger calls = new AtomicInteger();
Predicate<Integer> expensive = price -> {
    calls.incrementAndGet();
    return price % 20 == 0;
};
Predicate<Integer> cheap = price -> price <= 100;
List<Integer> prices = List.of(80, 120, 140, 160, 200);

List<Integer> cheapFirst = prices.stream().filter(cheap.and(expensive)).toList();    // [80]
int callsCheapFirst = calls.getAndSet(0);                                          // 1
List<Integer> expensiveFirst = prices.stream().filter(expensive.and(cheap)).toList();   // [80]
int callsExpensiveFirst = calls.get();                                             // 5

Both orders keep the same price, but the expensive check runs once instead of five times. We put cheap and selective conditions first, and checks that call a database, a remote service or a regular expression last.

4. Writing Null-Safe Conditions

A field that can be null makes a combined condition throw a NullPointerException as soon as one element has the null value. Short-circuit evaluation helps here, because a null check on the left side of && protects the call on the right side.

List<Room> rooms = Arrays.asList(new Room(101, 1, 80, true, null), new Room(102, 2, 120, true, "sea"));

List<Integer> unsafe = rooms.stream().filter(r -> r.view().equals("sea")).map(Room::number).toList();                          // NullPointerException
List<Integer> guarded = rooms.stream().filter(r -> r.view() != null && r.view().equals("sea")).map(Room::number).toList();       // [102]
List<Integer> constantFirst = rooms.stream().filter(r -> "sea".equals(r.view())).map(Room::number).toList();                    // [102]

Calling equals() on the constant “sea” is the shortest null-safe form, and Objects.equals(r.view(), “sea”) works the same way when both sides can be null. Records with optional fields are a common source of these nulls, so a compact constructor that rejects or replaces them removes the problem for every filter.

5. Building a Filter From Optional Search Criteria

A hotel booking site has a search form with three optional fields, namely minimum beds, maximum price and sea view. Users fill in any combination of them, and the backend must apply only the fields that were sent. Writing one if branch per combination does not scale, because three fields already give eight combinations.

The following example is a RoomSearch record whose fields are null when the user left them empty. The matcher() method collects one predicate per filled-in field and combines them with reduce(), so the stream pipeline stays the same for every search.

record RoomSearch(Integer minBeds, Integer maxPrice, Boolean seaView) {
    Predicate<Room> matcher() {
        List<Predicate<Room>> conditions = new ArrayList<>();
        conditions.add(Room::available);
        if (minBeds != null) conditions.add(r -> r.beds() >= minBeds);
        if (maxPrice != null) conditions.add(r -> r.price() <= maxPrice);
        if (seaView != null) conditions.add(r -> seaView == "sea".equals(r.view()));
        return conditions.stream().reduce(r -> true, Predicate::and);
    }
}
List<Room> rooms = List.of(
        new Room(101, 1, 80, true, "garden"),
        new Room(102, 2, 120, true, "sea"),
        new Room(201, 2, 150, false, "sea"),
        new Room(202, 3, 200, true, "city"));

List<Integer> anyFree = rooms.stream().filter(new RoomSearch(null, null, null).matcher()).map(Room::number).toList();    // [101, 102, 202]
List<Integer> twoBeds = rooms.stream().filter(new RoomSearch(2, null, null).matcher()).map(Room::number).toList();       // [102, 202]
List<Integer> budgetSea = rooms.stream().filter(new RoomSearch(null, 150, true).matcher()).map(Room::number).toList();   // [102]

The identity predicate r -> true is the starting value of reduce(), so a list with only the availability check still works. Adding a new search field means adding one if line in matcher(), and each condition can be unit tested on its own. Spring Data JPA uses the same idea with Specification objects when the filtering has to happen in the database instead of in memory.

6. Combining a List of Predicates With OR

The same reduce() pattern works for OR, with Predicate::or as the accumulator and x -> false as the starting value. The starting values matter for an empty list, because an empty AND keeps every element and an empty OR keeps none.

List<Predicate<String>> rules = List.of(s -> s.startsWith("tea"), s -> s.endsWith("jam"));
List<Predicate<String>> noRules = List.of();
List<String> items = List.of("tea bags", "apple jam", "bread");

List<String> anyRule = items.stream().filter(rules.stream().reduce(s -> false, Predicate::or)).toList();       // [tea bags, apple jam]
List<String> allRules = items.stream().filter(rules.stream().reduce(s -> true, Predicate::and)).toList();      // []
List<String> emptyOr = items.stream().filter(noRules.stream().reduce(s -> false, Predicate::or)).toList();     // []
List<String> emptyAnd = items.stream().filter(noRules.stream().reduce(s -> true, Predicate::and)).toList();    // [tea bags, apple jam, bread]

An equivalent form without reduce() tests each element against the list with anyMatch(), as in filter(s -> rules.stream().anyMatch(rule -> rule.test(s))), which reads well when the rules come from configuration.

7. Java Stream Filter With Multiple Conditions FAQs

Speed, OR logic and filtering by a set of values decide most multi-condition filters in code reviews.

7.1. Is one filter() with && faster than several filter() calls?

Not in a way that matters for most code. Several filters add a few method calls per element, which is small next to the cost of the conditions. Both forms stop at the first failing condition, so the order of the conditions, as shown in section 3, has a bigger effect than the number of filters.

7.2. How do I filter a stream with OR conditions?

Use || inside one lambda or or() on a Predicate. Chained filter() calls always mean AND, so they cannot express OR.

7.3. How do I filter by several values of the same field?

Put the allowed values into a Set and test with contains(), which is shorter than a long chain of || conditions and runs in constant time on average for hash-based sets such as Set.of() and HashSet.

Set<String> views = Set.of("sea", "garden");
List<Integer> nice = Stream.of(new Room(101, 1, 80, true, "garden"), new Room(202, 3, 200, true, "city")).filter(r -> views.contains(r.view())).map(Room::number).toList();   // [101]

7.4. How do I negate a combined condition?

Call negate() on the combined predicate or wrap it in Predicate.not(), as in Predicate.not(free.and(seaView)). In a lambda, we write !(a && b) with the parentheses, which equals !a || !b.

8. Conclusion

Multiple conditions in a stream filter come down to one predicate per filter() call. We write short conditions as one lambda with && and || and parentheses, long AND conditions as chained filter() calls, and reusable or runtime conditions as named Predicate objects combined with and(), or() and negate().

Cheap conditions go first because of short-circuit evaluation, null checks go before the calls they protect, and optional search criteria become a list of predicates reduced with Predicate::and. The other stream operations are collected in the Java Stream tutorials.

9. References

Happy Learning !!

Source Code on Github

Leave a Comment

  1. In the program given above “e.getId” will return a ‘long’ primitive data type. “toString” cannot be called on primitive types. It has to be on the boxed data type ‘Long’.

    Ex:- map(e -> ((Long)e.getId()).toString())
    
  2. Active employees with active accounts = [4,5,6]

    Right will be

    Active employees with inactive accounts = [4,5,6]

Comments are closed.

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.