Skip to content

Java · Collections

Java Comparable vs Comparator — Sorting, Chaining & the Pitfalls

7 min read Updated 2026-06-20 Share:

Practice Comparable & Comparator interview questions

Two ways to say "smaller"

Almost every Java program eventually has to put things in order, and the JDK gives you two complementary hooks for it. Comparable lets a type declare its own ordering; Comparator lets you describe an ordering from the outside, as many different ways as you like. Knowing which to reach for — and how the sorting machinery, the compareTo contract, and sorted collections all lean on them — is the difference between code that sorts correctly and code that quietly returns garbage. This guide ties the whole picture together.

Natural ordering with Comparable

A type that implements Comparable<T> (from java.lang) defines its natural ordering by overriding a single method, compareTo. The contract is the same one every sort relies on: return a negative int when this sorts before the argument, zero when they tie, and positive when this sorts after. Only the sign matters, never the magnitude.

class Person implements Comparable<Person> {
  String name; int age;
  @Override public int compareTo(Person other) {
    return Integer.compare(this.age, other.age); // natural order = by age
  }
}
// Now Person works with no comparator anywhere:
Collections.sort(people);            // uses compareTo
TreeSet<Person> ts = new TreeSet<>(); // uses compareTo

Implement Comparable for the one obvious ordering a type genuinely is — numbers by value, strings alphabetically, money by amount. Once it's there, Collections.sort, Arrays.sort, TreeSet, TreeMap, and Stream.sorted() all just work without an extra argument.

External ordering with Comparator

A Comparator<T> (from java.util) is a separate object that orders two elements via compare(a, b), using the identical sign convention. Because it lives outside the class, you can define unlimited orderings, and you can sort types you can't edit (String, library classes, your colleague's untouchable model).

// order Person by name without touching the Person class
Comparator<Person> byName = (a, b) -> a.name.compareTo(b.name);
people.sort(byName);

The two interfaces map onto each other cleanly:

Comparable<T>Comparator<T>
Packagejava.langjava.util
Methodint compareTo(T o)int compare(T a, T b)
Livesinside the classa separate object
Orderingsone (the natural one)as many as you want
Edits the class?yesno

Rule of thumb: Comparable for the single intrinsic order, Comparator for everything situational — especially for types you don't own.

The contract — and the subtraction trap

The contract isn't a suggestion. A valid ordering must be antisymmetric (sgn(a.compareTo(b)) == -sgn(b.compareTo(a))), transitive (if a > b and b > c then a > c), and consistent (equal inputs always compare equally). Break transitivity and modern Arrays.sort will detect it and throw IllegalArgumentException: Comparison method violates its general contract!.

The most common way to break the contract by accident is the "clever" one-liner return a - b;. Integer subtraction can overflow: when a is large positive and b is large negative, a - b wraps past Integer.MAX_VALUE and comes back negative, so a value that should sort after sorts before.

int a = Integer.MAX_VALUE, b = -1;
System.out.println(a - b);                 // -2147483648  (wrong sign!)
System.out.println(Integer.compare(a, b)); // positive     (correct)

The fix is Integer.compare (or Long.compare, Double.compare): overflow-safe, self-documenting, no cleverness required. Never subtract to compare.

Building comparators by composition

You rarely hand-write compare anymore. The factory methods on Comparator build one from a key extractor and compose into multi-level orderings. comparing pulls a Comparable key out of each element; thenComparing adds a tie-breaker that's only consulted when the previous level returns zero; reversed flips direction; and comparingInt/comparingLong/comparingDouble take a primitive extractor to skip the autoboxing that plain comparing would do on every comparison.

people.sort(
  Comparator.comparing(Person::getLastName)   // primary key
            .thenComparing(Person::getFirstName) // tie-breaker
            .thenComparingInt(Person::getAge)    // primitive, no boxing
);
people.sort(Comparator.comparingInt(Person::getAge).reversed()); // oldest first

Two placement gotchas worth internalizing: reversed() flips the entire chain built so far, not just the last thenComparing, so parenthesize deliberately or reverse a single key with thenComparing(Person::getAge, reverseOrder()). And because comparators are stateless and thread-safe, build the ones you reuse as static final constants instead of rebuilding the chain in a hot loop.

Sorting null fields safely

A plain comparator throws NullPointerException the moment it meets a null element or a null key. Wrap it with Comparator.nullsFirst(...) or nullsLast(...) to decide where nulls land, and combine it with naturalOrder()/reverseOrder() so you never write (a, b) -> a.compareTo(b) by hand.

List<String> s = Arrays.asList("b", null, "a");
s.sort(Comparator.nullsFirst(Comparator.naturalOrder())); // [null, a, b]

// guard a possibly-null key inside a chain
people.sort(Comparator.comparing(Person::getName,
                                 Comparator.nullsLast(Comparator.naturalOrder())));

Rule of thumb: if a field can be null, wrap its comparator rather than hoping the data is clean.

One ordering, every sort

The payoff of having Comparable/Comparator as a shared currency is that every sorting entry point in the JDK accepts the same comparators. They split into two families: those that mutate in place and those that produce a new ordering.

Collections.sort(list);                                  // in place, natural order
list.sort(Comparator.comparing(Person::getName));        // in place, modern idiom
Arrays.sort(boxed, Comparator.reverseOrder());           // object array, in place
List<Person> byAge = people.stream()                     // NEW list, source untouched
    .sorted(Comparator.comparingInt(Person::getAge))
    .toList();
new TreeSet<>(String.CASE_INSENSITIVE_ORDER);            // kept sorted on every insert
new PriorityQueue<>(Comparator.reverseOrder());          // max-heap; head only

A few distinctions matter under questioning. Collections.sort simply delegates to list.sort, so prefer the method on the object you already hold. Arrays.sort accepts a comparator only for object arrays — primitive arrays sort natural-ascending only, so to sort int[] descending you box to Integer[] first. Stream.sorted does not mutate the source and is stateful (it buffers everything before emitting). And a PriorityQueue only guarantees the head is the min/max — iterating it does not yield sorted order, so trust peek/poll and nothing else. Object sorts (TimSort) are stable; primitive Arrays.sort (dual-pivot quicksort) is not.

Consistency with equals and the TreeSet dedup trap

Here's the subtlety that trips up even experienced developers. An ordering is consistent with equals when a.compareTo(b) == 0 exactly when a.equals(b). It's only recommended, not enforced — but sorted collections judge equality by comparison, not by equals. A TreeSet/TreeMap decides two elements are duplicates purely because their compare returns 0; equals and hashCode are never consulted.

// comparator looks only at age:
TreeSet<Person> set = new TreeSet<>(Comparator.comparingInt(Person::getAge));
set.add(new Person("Ada", 30));
set.add(new Person("Bob", 30));   // NOT added — same age -> compare == 0
System.out.println(set.size());   // 1, even though Ada != Bob

// the classic JDK inconsistency:
new BigDecimal("1.0").equals(new BigDecimal("1.00"));    // false
new BigDecimal("1.0").compareTo(new BigDecimal("1.00")); // 0  -> "equal" to a TreeSet

So a coarse comparator silently swallows distinct objects, and BigDecimal lands as one element in a TreeSet but two in a HashSet. Rule of thumb: make any TreeSet/TreeMap comparator total enough that only truly-equal elements compare to 0, and keep compareTo consistent with equals unless you have a documented reason not to.

Recap

Comparable defines a type's one natural ordering from the inside via compareTo; Comparator defines any number of external orderings via compare — both governed by the same sign-based, antisymmetric, transitive, consistent contract. Never subtract to compare (use Integer.compare to dodge overflow); build comparators with comparing / thenComparing / reversed / nullsFirst / comparingInt instead of hand-rolling compare; and remember that one comparator feeds every sort — Collections.sort, List.sort, Arrays.sort, Stream.sorted, TreeSet, and PriorityQueue alike. Above all, keep consistency with equals in mind: sorted collections de-duplicate by comparison, so a too-loose comparator will quietly drop elements you meant to keep.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel