Comparator: Sorting by Multiple Criteria with comparing and thenComparing
1. Overview
Real-world sorting problems rarely have just one criterion. For example: sort orders by priority, within the same priority put higher totals first, and if those are equal, put older orders first.
This article shows you how to build such comparators using Comparator.comparing and thenComparing. You’ll also learn how to handle null, use reversed() correctly, and avoid common comparator mistakes.
2. Preparing the Data
We’ll use a simple record for an order:
import java.time.LocalDate;
public record Order(String customer, int priority, double total, LocalDate createdAt) {
}
And a sample list. The initial order is An, Binh, Chi, Dung, Em:
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
public class SampleData {
public static List<Order> orders() {
return new ArrayList<>(List.of(
new Order("An", 2, 150.0, LocalDate.of(2026, 10, 1)),
new Order("Binh", 1, 300.0, LocalDate.of(2026, 10, 3)),
new Order("Chi", 2, 300.0, LocalDate.of(2026, 10, 2)),
new Order("Dung", 1, 300.0, LocalDate.of(2026, 10, 1)),
new Order("Em", 2, 150.0, LocalDate.of(2026, 9, 30))
));
}
}
We wrap it in new ArrayList<>(...) because List.of returns an immutable list. Calling sort on it would throw UnsupportedOperationException.
3. Comparator with a Single Criterion Using comparing
3.1. comparing with Method Reference
Comparator.comparing takes a key extractor function. This function returns a Comparable value, and the comparator compares those keys:
List<Order> orders = SampleData.orders();
orders.sort(Comparator.comparing(Order::customer));
System.out.println(orders.stream().map(Order::customer).toList());
[An, Binh, Chi, Dung, Em]
Order::customer returns a String. String already implements Comparable, so the comparator uses the natural order of strings.
3.2. comparingInt, comparingLong, comparingDouble
For primitive keys, use specialized variants. They avoid boxing to Integer or Double on each comparison:
List<Order> orders = SampleData.orders();
orders.sort(Comparator.comparingDouble(Order::total));
System.out.println(orders.stream().map(Order::customer).toList());
[An, Em, Binh, Chi, Dung]
An and Em both have a total of 150. They keep their original order because List.sort uses TimSort, a stable sorting algorithm. Elements that compare equal aren’t reordered.
3.3. comparing with a Comparator for the Key
The second overload of comparing takes an additional comparator for comparing keys. This lets you reverse just one criterion:
orders.sort(Comparator.comparing(Order::createdAt, Comparator.reverseOrder()));
This puts the newest orders first. This style is very useful when combining multiple criteria, as you’ll see in the next section.
4. Multiple Criteria with thenComparing
thenComparing is used only when the previous comparator returned 0—that is, the two elements tied on the previous criteria. Returning to the original requirement:
priorityascending (1 is highest priority).totaldescending.createdAtascending (older orders first).
Comparator<Order> byBusinessRule = Comparator
.comparingInt(Order::priority)
.thenComparing(Comparator.comparingDouble(Order::total).reversed())
.thenComparing(Order::createdAt);
List<Order> orders = SampleData.orders();
orders.sort(byBusinessRule);
System.out.println(orders.stream().map(Order::customer).toList());
[Dung, Binh, Chi, Em, An]
It proceeds step by step:
- The
priority = 1group has Binh and Dung. Both havetotal = 300, so the date criterion decides. Dung (10/01) comes before Binh (10/03). - The
priority = 2group has An, Chi, Em. Chi has 300 so comes first in the group. An and Em both have 150, so Em (09/30) comes before An (10/01).
Notice the second line: reversed() is called on a separate comparator for total. It doesn’t apply to the entire chain. This is where many people make a mistake.
5. The Pitfall of reversed()
reversed() reverses the entire comparator it’s called on. Look at this code:
Comparator<Order> wrong = Comparator
.comparingInt(Order::priority)
.thenComparingDouble(Order::total)
.reversed();
List<Order> orders = SampleData.orders();
orders.sort(wrong);
System.out.println(orders.stream().map(Order::customer).toList());
[Chi, An, Em, Binh, Dung]
The writer wants “priority ascending, total descending”. But the result is both descending: the priority = 2 group gets pushed to the top. The reason is that reversed() is applied to a comparator already combined from two criteria.
There are two correct ways—pick whichever is more readable for you:
// Approach 1: separate comparator for the key, then reverse
Comparator<Order> c1 = Comparator.comparingInt(Order::priority)
.thenComparing(Comparator.comparingDouble(Order::total).reversed());
// Approach 2: pass the key's comparator into thenComparing
Comparator<Order> c2 = Comparator.comparingInt(Order::priority)
.thenComparing(Order::total, Comparator.reverseOrder());
Approach 2 is shorter but requires boxing double to Double. With thousands of elements, this difference is negligible. With large data sorted continuously, prefer Approach 1.
Note: Calling
reversed()at the end of the chain is correct only if you truly want to reverse all criteria. If you want to reverse just one criterion, reverse it at that criterion itself.
6. Handling null Values
6.1. The Key Can Be null
Comparator.comparing(Customer::nickname) throws NullPointerException if it encounters a null nickname. Wrap the key’s comparator with nullsFirst or nullsLast:
public record Customer(String nickname) {
}
List<Customer> customers = new ArrayList<>(List.of(
new Customer("lan"),
new Customer(null),
new Customer("An"),
new Customer("binh"),
new Customer("Cuong")
));
customers.sort(Comparator.comparing(
Customer::nickname,
Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)
));
System.out.println(customers.stream().map(Customer::nickname).toList());
[An, binh, Cuong, lan, null]
This example uses String.CASE_INSENSITIVE_ORDER, so uppercase and lowercase are compared equally. If you use Comparator.naturalOrder(), the result would be [An, Cuong, binh, lan, null]. The reason is that the natural order of String compares by Unicode code point, and uppercase letters come before lowercase.
6.2. The Element Itself Can Be null
If the elements themselves in the list can be null, wrap the entire comparator on the outside:
Comparator<Order> safe = Comparator.nullsFirst(byBusinessRule);
These two cases are different. Wrapping the key doesn’t protect you from null elements, and wrapping elements doesn’t protect you from null keys.
7. Type Inference Errors with Lambdas
The following code doesn’t compile:
// Error: cannot find symbol: method customer() in class Object
Comparator<Order> c = Comparator.comparing(o -> o.customer())
.thenComparing(Order::createdAt);
The compiler infers types left to right in the method call chain. At the comparing(...) call, it doesn’t yet know the final target is Comparator<Order>, so the parameter o is inferred as Object.
There are three ways to fix it:
Comparator.comparing(Order::customer) // method reference
Comparator.comparing((Order o) -> o.customer()) // declare parameter type
Comparator.<Order, String>comparing(o -> o.customer()) // type witness
Method reference is the most concise. Use it whenever you can.
8. Testing Comparators
Comparators contain business logic, so they deserve separate tests. Add these dependencies:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
Place your comparator in a fixed location for easy reuse:
import java.util.Comparator;
public final class OrderComparators {
public static final Comparator<Order> BY_BUSINESS_RULE = Comparator
.comparingInt(Order::priority)
.thenComparing(Comparator.comparingDouble(Order::total).reversed())
.thenComparing(Order::createdAt);
private OrderComparators() {
}
}
Then write the tests:
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class OrderComparatorsTest {
@Test
void givenOrders_whenSortByBusinessRule_thenPriorityAscTotalDescDateAsc() {
List<Order> orders = SampleData.orders();
orders.sort(OrderComparators.BY_BUSINESS_RULE);
assertThat(orders)
.extracting(Order::customer)
.containsExactly("Dung", "Binh", "Chi", "Em", "An");
assertThat(orders).isSortedAccordingTo(OrderComparators.BY_BUSINESS_RULE);
}
@Test
void givenReversedAtEnd_whenSort_thenAllCriteriaAreReversed() {
List<Order> orders = SampleData.orders();
orders.sort(java.util.Comparator.comparingInt(Order::priority)
.thenComparingDouble(Order::total)
.reversed());
assertThat(orders.getFirst().priority()).isEqualTo(2);
}
}
containsExactly checks the exact order of the result. isSortedAccordingTo verifies that the list is sorted according to that comparator. The second test documents the behavior of reversed() at the end of the chain, so others don’t repeat the mistake. getFirst() is a new List method in Java 21.
9. Common Mistakes and When Not to Use This Approach
9.1. Comparing by Subtraction
// Don't do this
Comparator<Order> bad = (a, b) -> a.priority() - b.priority();
Subtraction can overflow. For example, Integer.MIN_VALUE - 1 produces a large positive number, so the comparison result is wrong. Use Comparator.comparingInt(...) or Integer.compare(a, b) instead.
9.2. Comparator Inconsistent with equals in TreeSet/TreeMap
TreeSet considers two elements equal when the comparator returns 0. It doesn’t use equals to test for equality:
Set<Order> set = new TreeSet<>(Comparator.comparingInt(Order::priority));
set.addAll(SampleData.orders());
System.out.println(set.size());
2
Five orders are reduced to two, one per priority. If you use a comparator for TreeSet or TreeMap, add a final criterion that’s unique—such as an order ID. Then the comparator returns 0 only for elements that truly coincide.
9.3. When Not to Use the comparing Chain
- Criteria depend on each other. For example: “VIP orders by total, regular orders by date”. Writing a lambda comparator with
ifwill be clearer than a contortedthenComparingchain. - The key is expensive to compute. The key extractor is called on every comparison, roughly
n log ntimes. If the key must parse a string or call a service, compute it once upfront, then sort. - Sorting can be done in the database. If data comes from a SQL query,
ORDER BYis usually faster and avoids pulling excess data into memory.
10. Conclusion
In this article, you’ve learned how to:
- Use
comparing,comparingInt, andcomparingDoubleto create a comparator from a key. - Chain multiple criteria with
thenComparing, and reverse just one criterion usingreversed()orComparator.reverseOrder(). - Handle
nullwithnullsFirstandnullsLast, at both the key level and element level. - Avoid type inference errors with lambdas, overflow errors with subtraction, and element loss in
TreeSet.
Make business comparators named constants, and always include a test that checks the expected order. When sorting rules change, the test will alert you immediately.