> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# How to Implement Comparable and Comparator in Java 21
- URL: https://www.ggorantala.dev/how-to-implement-comparable-comparator-java/
- Published: 2026-10-29T05:01:00.000Z
- Updated: 2026-10-31T06:35:36.000Z
- Description: Sort a warehouse stock list by SKU, name, price and expiry date with Comparable and Comparator on Java 21, then prove the order with 28 runnable checks.
- Author: Gopi Gorantala
- Tags: how-to-tutorials, java-collections, java-tutorials

## Sort the warehouse three ways: what you'll build

Here's the bug that made me write this one down. Two stock lines for the same SKU go into a `TreeSet`. One line has 5 units, the other has 9\. The set reports a size of 1, keeps the 5, and says nothing:

```log
TreeSet size after adding two SKU-1 lines: 1 (quantity kept: 5)

```

No exception, no warning. Four units of tea are gone from the report because `compareTo` said the two lines were the same item. That's the kind of mistake you only avoid once you know how Java decides that two objects are "equal" for sorting purposes.

By the end of this tutorial you'll know how to implement Comparable and Comparator in Java the way I'd write them at work. You'll have a small inventory project with a `StockItem` that has a natural order, a handful of named comparators, one chained "restock report" order that handles missing expiry dates, and 28 checks that prove each of them follows the sorting contract. This is for you if you've called `Collections.sort` a hundred times and never been sure what happens when two items tie.

Here's what the finished program prints. Items are sorted by category, then soonest expiry, then name, with undated items last in each category:

```log
restock report:
  dairy  Yoghurt       2026-10-30   SKU-400
  dairy  Almond milk   2026-11-02   SKU-100
  dairy  oat milk      2026-11-10   SKU-300
  pantry Lentils       2027-03-01   SKU-250
  pantry Basmati rice  null         SKU-200
  pantry Brown rice    null         SKU-150

```

The whole thing is four small Java files and no libraries. The build itself went quickly. Most of my time went on one stubborn failure: a broken comparator that refused to throw an exception on small lists, which took three attempts to reproduce. More on that in the troubleshooting section.

## Tools I used and how to check yours

| Tool                    | Version I used                     | Check it with  |
| ----------------------- | ---------------------------------- | -------------- |
| JDK                     | OpenJDK 21.0.12.1 (Ubuntu build)   | java -version  |
| javac                   | 21.0.12.1                          | javac -version |
| IntelliJ IDEA / Eclipse | optional, not run for this article | Help → About   |

You need Java 21 or any release from 16 up, because `StockItem` is a record. If you're on an older JDK, the compiler stops you with `records are not supported in -source 11`. I'll show that message later. You should already know how to write a class and a `List`, and it helps to have read [how to use Java records](https://ggorantala.dev/how-to-use-java-records/?ref=ggorantala.dev) first. No framework, no database, no container. Podman and Docker have no part in this one.

I didn't use Maven or Gradle this time. My build environment can't reach Maven Central, so I compiled with plain `javac` and wrote the checks as an ordinary `main` method instead of JUnit. I'd rather tell you that than quietly drop in a JUnit version I didn't run. The project layout below is the standard Maven/Gradle one, so everything moves over unchanged when you want it there.

## Create the inventory folders and compile once

Open a terminal and make the folders:

```bash
mkdir -p inv/src/main/java/com/example/inventory inv/src/test/java/com/example/inventory
cd inv

```

The tree you're aiming for:

```log
inv/
└── src/
    ├── main/java/com/example/inventory/
    │   ├── StockItem.java
    │   ├── StockOrders.java
    │   └── Main.java
    └── test/java/com/example/inventory/
        └── Checks.java

```

**In IntelliJ IDEA:** New Project, choose a plain Java project (Maven or Gradle if you like), pick JDK 21, then create the `com.example.inventory` package under `src/main/java`. **In Eclipse:** File → New → Java Project, set the execution environment to JavaSE-21, and add the package the same way. I wrote those two paths from the IDE docs and didn't click through them for this article. The Java code is identical in both.

Everything below compiles from the command line with one command, which I'll repeat at each checkpoint:

```bash
mkdir -p out
javac --release 21 -d out src/main/java/com/example/inventory/*.java

```

## How to implement Comparable and Comparator in Java: five steps

Two interfaces, two jobs. `Comparable<T>` lives inside the class and defines its one natural order. `Comparator<T>` lives outside and defines any other order you need. Most real code uses one `Comparable` and several `Comparator`s, and that's what we'll build.

### Step 1: Give StockItem a natural order with Comparable

**Goal:** make `StockItem` sortable on its own, so `Collections.sort(list)` and `TreeSet` work without being told how.

`src/main/java/com/example/inventory/StockItem.java`

```java
package com.example.inventory;

import java.math.BigDecimal;
import java.time.LocalDate;

/**
 * One line in the warehouse. The natural order is by SKU, because a SKU
 * identifies an item. Nothing else about the item is allowed to decide it.
 */
public record StockItem(String sku, String name, String category,
                        int quantity, BigDecimal price, LocalDate expiry)
        implements Comparable<StockItem> {

    public StockItem {
        if (sku == null || sku.isBlank()) throw new IllegalArgumentException("sku is required");
        if (name == null || category == null || price == null) {
            throw new IllegalArgumentException("name, category and price are required");
        }
    }

    @Override
    public int compareTo(StockItem other) {
        return sku.compareTo(other.sku);
    }
}

```

**Why this way:** `compareTo` returns a negative number if this item sorts first, zero if the two are tied, and a positive number if this one sorts later. The sign is all that matters. I compare the SKU only, because the natural order should answer the question "which item is this?" and nothing more. `expiry` is allowed to be null (rice doesn't expire in any way we track), so the compact constructor doesn't check it.

There's a catch hiding in this file and we'll trigger it on purpose in Step 5: `compareTo` ignores everything except the SKU, but a record's generated `equals` compares every field. The two methods disagree. Hold that thought.

**Checkpoint:** you can't run anything yet, because `Main` doesn't exist. Compile only this file to be sure it parses:

```bash
javac --release 21 -d out src/main/java/com/example/inventory/StockItem.java

```

No output means success.

### Step 2: Write the first named comparators

**Goal:** sort by name, price, and quantity without touching `StockItem`.

`src/main/java/com/example/inventory/StockOrders.java`

```java
package com.example.inventory;

import java.util.Comparator;

public final class StockOrders {

    private StockOrders() { }

    public static final Comparator<StockItem> BY_NAME =
            Comparator.comparing(StockItem::name, String.CASE_INSENSITIVE_ORDER);

    public static final Comparator<StockItem> BY_PRICE =
            Comparator.comparing(StockItem::price);

    // Highest stock first. Integer.compare, never a subtraction.
    public static final Comparator<StockItem> BY_QUANTITY_DESC =
            Comparator.comparingInt(StockItem::quantity).reversed();
}

```

**Why:** `Comparator.comparing` takes a "key extractor" (a method reference that pulls out the field to sort on) and builds the comparator for you. Passing `String.CASE_INSENSITIVE_ORDER` as a second argument matters more than it looks. My sample data has `oat milk` in lower case, and plain `String` ordering puts every lower-case word after every capitalised one. With the case-insensitive comparator `oat milk` lands between `Lentils` and `Yoghurt`, which is where a human expects it. `comparingInt` avoids boxing, and `.reversed()` flips the result.

**Checkpoint:** compile both files together.

```bash
javac --release 21 -d out src/main/java/com/example/inventory/*.java

```

### Step 3: Chain comparators and handle null dates

**Goal:** build the restock report order: category, then soonest expiry, then name, then SKU, with missing expiry dates last.

Replace `StockOrders.java` with this complete file:

```java
package com.example.inventory;

import java.util.Comparator;

public final class StockOrders {

    private StockOrders() { }

    public static final Comparator<StockItem> BY_NAME =
            Comparator.comparing(StockItem::name, String.CASE_INSENSITIVE_ORDER);

    public static final Comparator<StockItem> BY_PRICE =
            Comparator.comparing(StockItem::price);

    // Highest stock first. Integer.compare, never a subtraction.
    public static final Comparator<StockItem> BY_QUANTITY_DESC =
            Comparator.comparingInt(StockItem::quantity).reversed();

    // Items with no expiry date (null) go last.
    public static final Comparator<StockItem> BY_EXPIRY_NULLS_LAST =
            Comparator.comparing(StockItem::expiry,
                    Comparator.nullsLast(Comparator.naturalOrder()));

    // Category A-Z, then soonest expiry, then name, then SKU as the final tie-break.
    public static final Comparator<StockItem> RESTOCK_REPORT =
            Comparator.comparing(StockItem::category)
                    .thenComparing(BY_EXPIRY_NULLS_LAST)
                    .thenComparing(BY_NAME)
                    .thenComparing(Comparator.naturalOrder());
}

```

**Why:** `thenComparing` only runs when everything before it tied. The last line uses the natural order (the SKU) as a tie-break, so two items never compare as equal unless they share a SKU. That makes the report order fully deterministic. `nullsLast` wraps a comparator so that `null` never reaches it. Without the wrapper, the first null date throws a `NullPointerException`, and I check that in Step 5.

**Checkpoint:** compile again. Same command, no output.

### Step 4: Wire up a program and sort with it

**Goal:** use all three ways of sorting: natural order, a comparator on a list, and a comparator on a `TreeSet`.

`src/main/java/com/example/inventory/Main.java`

```java
package com.example.inventory;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.TreeSet;

public class Main {

    static StockItem item(String sku, String name, String cat, int qty, String price, String expiry) {
        return new StockItem(sku, name, cat, qty, new BigDecimal(price),
                expiry == null ? null : LocalDate.parse(expiry));
    }

    public static List<StockItem> sample() {
        return new ArrayList<>(List.of(
                item("SKU-300", "oat milk",    "dairy",  40, "2.49", "2026-11-10"),
                item("SKU-100", "Almond milk", "dairy",  12, "2.99", "2026-11-02"),
                item("SKU-200", "Basmati rice","pantry", 75, "3.50", null),
                item("SKU-150", "Brown rice",  "pantry", 75, "3.10", null),
                item("SKU-400", "Yoghurt",     "dairy",  12, "1.20", "2026-10-30"),
                item("SKU-250", "Lentils",     "pantry",  5, "1.80", "2027-03-01")));
    }

    public static void main(String[] args) {
        List<StockItem> items = sample();

        items.sort(null); // null = natural order
        System.out.println("natural (SKU):");
        items.forEach(i -> System.out.println("  " + i.sku() + "  " + i.name()));

        items.sort(StockOrders.BY_NAME);
        System.out.println("by name, ignoring case:");
        items.forEach(i -> System.out.println("  " + i.name()));

        items.sort(StockOrders.RESTOCK_REPORT);
        System.out.println("restock report:");
        items.forEach(i -> System.out.printf("  %-6s %-13s %-12s %s%n",
                i.category(), i.name(), i.expiry(), i.sku()));

        TreeSet<StockItem> byName = new TreeSet<>(StockOrders.BY_NAME);
        byName.addAll(items);
        System.out.println("first alphabetically: " + byName.first().name());
    }
}

```

**Why:** `List.of` is immutable, so the sample is copied into an `ArrayList` that `sort` can rearrange. `items.sort(null)` is documented to mean "use the natural order". It reads oddly and I'd normally write `Collections.sort(items)` or `Comparator.naturalOrder()` in real code. I used `null` here once so you've seen it. The `TreeSet` takes the comparator in its constructor and uses it for everything: ordering, lookups, and deciding what counts as a duplicate.

**Checkpoint:**

```bash
javac --release 21 -d out src/main/java/com/example/inventory/*.java
java -cp out com.example.inventory.Main

```

You should see exactly this:

```log
natural (SKU):
  SKU-100  Almond milk
  SKU-150  Brown rice
  SKU-200  Basmati rice
  SKU-250  Lentils
  SKU-300  oat milk
  SKU-400  Yoghurt
by name, ignoring case:
  Almond milk
  Basmati rice
  Brown rice
  Lentils
  oat milk
  Yoghurt
restock report:
  dairy  Yoghurt       2026-10-30   SKU-400
  dairy  Almond milk   2026-11-02   SKU-100
  dairy  oat milk      2026-11-10   SKU-300
  pantry Lentils       2027-03-01   SKU-250
  pantry Basmati rice  null         SKU-200
  pantry Brown rice    null         SKU-150
first alphabetically: Almond milk

```

If you see that, the hard part's done. (If you've got a Java 21 JDK, `java -version` printed a line starting with `openjdk version "21` and you can ignore any `Picked up JAVA_TOOL_OPTIONS` noise that your environment adds above the output. I had it on every command.)

### Step 5: Break things on purpose

**Goal:** see the three comparator mistakes that cost real money in real code, so you recognise them.

Add this comparator to `StockOrders.java`, above the closing brace. It's the version almost every beginner writes, and I've written it myself:

```java
    // The classic bug, kept here on purpose so the checks can catch it.
    public static final Comparator<StockItem> BROKEN_BY_QUANTITY =
            (a, b) -> a.quantity() - b.quantity();

```

Now sort two items, one with 2,147,483,647 units and one with -5 (a data-entry error, but it happens). Put this in a scratch class next to `Main`, say `Scratch.java`, with `new StockItem("SKU-2", "big", "x", Integer.MAX_VALUE, BigDecimal.ONE, null)` and `new StockItem("SKU-3", "neg", "x", -5, BigDecimal.ONE, null)` in an `ArrayList`, then call `list.sort(StockOrders.BROKEN_BY_QUANTITY)` and print the quantities. I got:

```log
broken ascending by quantity: [2147483647, -5]
fixed ascending by quantity:  [-5, 2147483647]

```

`2147483647 - (-5)` overflows `int` and wraps to a large negative number, so the comparator claims the big item is smaller. Use `Integer.compare(a, b)` or `Comparator.comparingInt`. Never subtract.

The second mistake is the `TreeSet` that opened this article. `compareTo` returned 0 for two lines with the same SKU, so the set treated the second as a duplicate and refused it. `HashSet` keeps both, because it uses `equals`. I cover that rule properly in [how to implement equals() and hashCode()](https://ggorantala.dev/how-to-implement-equals-hashcode-java/?ref=ggorantala.dev). The short version: if two objects compare as 0, they should also be `equals`, or you need to know exactly why they aren't.

The third mistake is a comparator that breaks the contract so badly the JDK notices. That one gets its own entry in the troubleshooting section.

## Run it and read the report

You've already run it. Read the three blocks of output once more with a specific question in mind: where did the stable sort matter? `Basmati rice` and `Brown rice` both have 75 units. Sorting with `BY_QUANTITY_DESC` leaves them in their incoming order, because `List.sort` is stable (it never swaps tied elements). That's why the report needs the SKU tie-break at the end. A stable sort hides the problem; a `TreeSet` doesn't.

## Prove the contract with 28 checks

A comparator has three rules. `compare(a, b)` and `compare(b, a)` must have opposite signs. If `a <= b` and `b <= c` then `a <= c`. And a tie should mean the same thing everywhere. I wanted each of those tested, not assumed, so the check class runs the first two over every comparator in the project against 60 random items.

`src/test/java/com/example/inventory/Checks.java`

```java
package com.example.inventory;

import java.util.*;
import java.math.BigDecimal;
import java.time.LocalDate;

public class Checks {
    static int passed = 0, failed = 0;

    static void check(String name, boolean ok) {
        if (ok) { passed++; System.out.println("PASS  " + name); }
        else { failed++; System.out.println("FAIL  " + name); }
    }

    static StockItem it(String sku, String name, int qty, String price) {
        return new StockItem(sku, name, "x", qty, new BigDecimal(price), null);
    }

    public static void main(String[] args) {
        List<StockItem> s = Main.sample();

        // natural order
        List<StockItem> nat = new ArrayList<>(s); Collections.sort(nat);
        check("natural order is ascending by sku",
                nat.stream().map(StockItem::sku).toList()
                   .equals(List.of("SKU-100","SKU-150","SKU-200","SKU-250","SKU-300","SKU-400")));

        // sign symmetry and transitivity over random items for every comparator
        Random r = new Random(42);
        List<StockItem> many = new ArrayList<>();
        String[] cats = {"dairy","pantry","frozen"};
        for (int i = 0; i < 60; i++) {
            many.add(new StockItem("SKU-" + r.nextInt(30), (r.nextBoolean() ? "a" : "B") + r.nextInt(8),
                    cats[r.nextInt(3)], r.nextInt(5), new BigDecimal(r.nextInt(4) + ".50"),
                    r.nextInt(3) == 0 ? null : LocalDate.of(2026, 11, 1 + r.nextInt(5))));
        }
        Map<String, Comparator<StockItem>> all = new LinkedHashMap<>();
        all.put("natural", Comparator.naturalOrder());
        all.put("BY_NAME", StockOrders.BY_NAME);
        all.put("BY_PRICE", StockOrders.BY_PRICE);
        all.put("BY_QUANTITY_DESC", StockOrders.BY_QUANTITY_DESC);
        all.put("BY_EXPIRY_NULLS_LAST", StockOrders.BY_EXPIRY_NULLS_LAST);
        all.put("RESTOCK_REPORT", StockOrders.RESTOCK_REPORT);
        for (var e : all.entrySet()) {
            Comparator<StockItem> c = e.getValue();
            boolean sym = true, trans = true;
            for (StockItem a : many) for (StockItem b : many) {
                if (Integer.signum(c.compare(a, b)) != -Integer.signum(c.compare(b, a))) sym = false;
            }
            for (int i = 0; i < 20000; i++) {
                StockItem a = many.get(r.nextInt(60)), b = many.get(r.nextInt(60)), d = many.get(r.nextInt(60));
                if (c.compare(a, b) <= 0 && c.compare(b, d) <= 0 && c.compare(a, d) > 0) trans = false;
            }
            check(e.getKey() + ": sgn(compare(a,b)) == -sgn(compare(b,a))", sym);
            check(e.getKey() + ": transitive on 20,000 random triples", trans);
        }

        // report order
        List<StockItem> rep = new ArrayList<>(s); rep.sort(StockOrders.RESTOCK_REPORT);
        check("restock report: Yoghurt (expires first) leads",
                rep.get(0).name().equals("Yoghurt"));
        check("restock report: null expiry sorts after dated items within a category",
                rep.get(3).name().equals("Lentils") && rep.get(4).expiry() == null);

        // sort is stable
        List<StockItem> st = new ArrayList<>(s); st.sort(StockOrders.BY_QUANTITY_DESC);
        int iB = st.indexOf(s.stream().filter(x -> x.sku().equals("SKU-200")).findFirst().get());
        int iR = st.indexOf(s.stream().filter(x -> x.sku().equals("SKU-150")).findFirst().get());
        check("List.sort is stable (equal quantities keep input order: 200 before 150)", iB < iR);

        // case-insensitive name
        List<StockItem> n = new ArrayList<>(s); n.sort(StockOrders.BY_NAME);
        check("BY_NAME puts 'oat milk' between Lentils and Yoghurt",
                n.get(4).name().equals("oat milk"));
        List<StockItem> plain = new ArrayList<>(s); plain.sort(Comparator.comparing(StockItem::name));
        check("plain String order puts 'oat milk' last (lower case after upper)",
                plain.get(5).name().equals("oat milk"));

        // consistency with equals
        StockItem a1 = it("SKU-1", "tea", 5, "1.00");
        StockItem a2 = it("SKU-1", "tea", 9, "1.00");
        check("compareTo == 0 while equals is false (record equals uses every field)",
                a1.compareTo(a2) == 0 && !a1.equals(a2));
        TreeSet<StockItem> ts = new TreeSet<>(List.of(a1, a2));
        check("TreeSet silently keeps one of two 'equal by compareTo' items", ts.size() == 1);
        HashSet<StockItem> hs = new HashSet<>(List.of(a1, a2));
        check("HashSet keeps both (equals disagrees)", hs.size() == 2);

        // subtraction overflow
        StockItem big = it("SKU-2", "x", Integer.MAX_VALUE, "1");
        StockItem neg = it("SKU-3", "y", -5, "1");
        check("subtraction comparator is WRONG on MAX_VALUE vs -5",
                StockOrders.BROKEN_BY_QUANTITY.compare(big, neg) < 0);
        check("Integer.compare gets it right", Integer.compare(big.quantity(), neg.quantity()) > 0);

        // reversed() and thenComparing
        check("reversed comparator flips sign",
                StockOrders.BY_PRICE.reversed().compare(it("A","a",1,"1"), it("B","b",1,"2")) > 0);

        // Comparator.nullsFirst vs NPE
        boolean npe = false;
        try { Comparator.comparing(StockItem::expiry).compare(s.get(0), s.get(2)); }
        catch (NullPointerException e) { npe = true; }
        check("comparing(expiry) without nullsLast throws NPE on a null date", npe);

        // BigDecimal scale
        StockItem p1 = it("P1","p",1,"2.0"), p2 = it("P2","p",1,"2.00");
        check("BigDecimal compareTo ignores scale (2.0 vs 2.00 -> 0) but equals does not",
                p1.price().compareTo(p2.price()) == 0 && !p1.price().equals(p2.price()));

        // Collections.max/min and binarySearch
        check("Collections.min with BY_PRICE finds Yoghurt", Collections.min(s, StockOrders.BY_PRICE).name().equals("Yoghurt"));
        List<StockItem> sorted = new ArrayList<>(s); Collections.sort(sorted);
        int pos = Collections.binarySearch(sorted, it("SKU-250","whatever",0,"0"));
        check("binarySearch uses compareTo, so a SKU-only probe finds the item", pos == 3);

        System.out.println(passed + " passed, " + failed + " failed");
        if (failed > 0) System.exit(1);
    }
}

```

Compile and run it. The classpath includes `out`, where the main classes already sit:

```bash
javac --release 21 -d out -cp out src/test/java/com/example/inventory/Checks.java
java -cp out com.example.inventory.Checks

```

```log
PASS  natural order is ascending by sku
PASS  natural: sgn(compare(a,b)) == -sgn(compare(b,a))
PASS  natural: transitive on 20,000 random triples
PASS  BY_NAME: sgn(compare(a,b)) == -sgn(compare(b,a))
PASS  BY_NAME: transitive on 20,000 random triples
[... lines cut: the same pair of lines for BY_PRICE, BY_QUANTITY_DESC, BY_EXPIRY_NULLS_LAST, RESTOCK_REPORT]
PASS  restock report: Yoghurt (expires first) leads
PASS  restock report: null expiry sorts after dated items within a category
PASS  List.sort is stable (equal quantities keep input order: 200 before 150)
PASS  BY_NAME puts 'oat milk' between Lentils and Yoghurt
PASS  plain String order puts 'oat milk' last (lower case after upper)
PASS  compareTo == 0 while equals is false (record equals uses every field)
PASS  TreeSet silently keeps one of two 'equal by compareTo' items
PASS  HashSet keeps both (equals disagrees)
PASS  subtraction comparator is WRONG on MAX_VALUE vs -5
PASS  Integer.compare gets it right
PASS  reversed comparator flips sign
PASS  comparing(expiry) without nullsLast throws NPE on a null date
PASS  BigDecimal compareTo ignores scale (2.0 vs 2.00 -> 0) but equals does not
PASS  Collections.min with BY_PRICE finds Yoghurt
PASS  binarySearch uses compareTo, so a SKU-only probe finds the item
28 passed, 0 failed

```

This is plain Java rather than JUnit, because of the Maven Central problem I mentioned. When you move it into a build tool, each `check(...)` becomes an `assertThat(...)` call in a `@Test` method, and the random-triple loop becomes a `@RepeatedTest` or a plain loop inside one test. I haven't run that translation, so I won't show output for it. The [JUnit 5 tutorial](https://ggorantala.dev/how-to-write-unit-tests-junit-5-java/?ref=ggorantala.dev) in this series is where that lands.

One check deserves a second look: `BigDecimal compareTo ignores scale`. `new BigDecimal("2.0").compareTo(new BigDecimal("2.00"))` is 0, while `equals` says false. It's the same trap as the TreeSet, built into the JDK. If you key a `TreeMap` on `BigDecimal` prices, 2.0 and 2.00 are one key.

## What sort() actually asks your code

When you call `list.sort(comparator)`, the JDK runs TimSort, a merge sort that takes advantage of runs that are already ordered. It never asks you for a "position". It only asks one question over and over: here are two items, which comes first? Your answer has to be consistent, because TimSort uses earlier answers to skip later comparisons. If `a < b` and `b < c` but you say `c < a`, the algorithm can end up in a state it believes is impossible.

`Comparable` is what a class says about itself, and the JDK already supplies it for `String`, the wrapper types, `BigDecimal`, `LocalDate` and enums. A `TreeSet` or `TreeMap` with no comparator calls `compareTo` and casts to `Comparable`, which is why putting a non-comparable object in one fails at runtime rather than at compile time. A `Comparator` is an outside opinion, passed in when you need a different order. That's what makes the same list sortable by price today and by expiry tomorrow without changing the class.

My rule: if there's one clearly right order (SKU, timestamp, version number), make the class `Comparable`. If there are several reasonable orders or the order depends on the screen, write comparators. When in doubt, comparators; you can always add `Comparable` later, but you can't take it away from callers who relied on it.

If you want the fuller picture of how a stream pipeline uses these, [how to filter and transform a list with Java streams](https://ggorantala.dev/how-to-filter-transform-list-java-streams/?ref=ggorantala.dev) uses `sorted(Comparator)` in a few places.

## Sorting bugs I caused on purpose

Each of these comes from something I ran. Exact messages, cause, then fix.

**A `TreeSet` quietly drops items.**

```log
TreeSet size after adding two SKU-1 lines: 1 (quantity kept: 5)

```

*Cause:* the comparator returned 0 for items you consider different. *Fix:* include every field that makes two items distinct in the comparison (add `thenComparing` for them), or use a `List` and not a `Set`.

**The set rejects a plain class with a `ClassCastException`.**

```log
class Traps$Plain cannot be cast to class java.lang.Comparable (Traps$Plain is in unnamed module of loader 'app'; java.lang.Comparable is in module java.base of loader 'bootstrap')

```

*Cause:* `new TreeSet<>().add(x)` without a comparator, where `x`'s class doesn't implement `Comparable`. The compiler can't see this when the set is declared as `TreeSet<Object>`. *Fix:* implement `Comparable<T>` or pass a `Comparator` to the constructor.

**`Collections.sort(list)` won't compile.**

```log
C.java:6: error: no suitable method found for sort(List<P>)
    method Collections.<T#1>sort(List<T#1>) is not applicable
      (inference variable T#1 has incompatible bounds
        equality constraints: P
        upper bounds: Comparable<? super T#1>)

```

*Cause:* `P` doesn't implement `Comparable`. This one's the friendly failure because it happens at compile time. *Fix:* implement `Comparable<P>`, or call `list.sort(someComparator)` instead.

**`compareTo(Object)` instead of `compareTo(P)`.**

```log
A.java:3: error: P is not abstract and does not override abstract method compareTo(P) in Comparable
A.java:4: error: name clash: compareTo(Object) in P and compareTo(P) in Comparable have the same erasure, yet neither overrides the other

```

*Cause:* the old raw-type signature. *Fix:* use `compareTo(P other)` and add `@Override` so the compiler checks it for you.

**`compareTo` without `public`.**

```log
D.java:4: error: compareTo(P) in P cannot implement compareTo(T) in Comparable
  attempting to assign weaker access privileges; was public

```

*Cause:* interface methods are public. *Fix:* add `public`.

**`Comparator.comparing(...).reversed()` loses its type.**

```log
B.java:6: error: cannot find symbol
        l.sort(Comparator.comparing(p -> p.s()).reversed());
  symbol:   method s()
  location: variable p of type Object

```

*Cause:* when you chain `.reversed()` the compiler no longer knows what `T` is for the lambda, so `p` becomes `Object`. This is the compile error that trips up more people than any other here. *Fix:* use a method reference (`Comparator.comparing(P::s).reversed()`), give the lambda a type (`(P p) -> p.s()`), or use `Comparator.comparing(P::s, Comparator.reverseOrder())`.

**`Comparison method violates its general contract!`**

```log
n=1000 IllegalArgumentException: Comparison method violates its general contract!
never-zero n=1000 IllegalArgumentException: Comparison method violates its general contract!

```

This was my stubborn one. I wrote a comparator that returned random numbers, then one that never returns 0 (`x < y ? -1 : 1`), and sorted 200 and then 100 items. Nothing happened: no exception, wrong order, silently. The JDK only catches a broken comparator when TimSort happens to merge in a way that exposes it. A cyclic comparator (0 < 1 < 2 < 0) threw at 1,000 elements and not at 40 or 100\. The never-zero comparator did the same. Third attempt, and I got the message.

*Cause:* the comparator isn't symmetric or isn't transitive. *Fix:* make it follow the three rules, test symmetry and transitivity like `Checks.java` does, and don't count on the JDK to catch it. The message appearing in production under load, on one customer's data, is the classic way people find out.

**A `null` date throws.** `Comparator.comparing(StockItem::expiry)` is fine until the first null:  
the check `comparing(expiry) without nullsLast throws NPE` proves it. *Fix:* wrap the key comparator in `Comparator.nullsFirst` or `nullsLast`.

**`records are not supported in -source 11`.** You compiled with an older `--release`:

```log
StockItem.java:10: error: records are not supported in -source 11
  (use -source 16 or higher to enable records)

```

*Fix:* use JDK 16 or later. If you must stay on 11, write `StockItem` as a normal final class with `equals` and `hashCode`. The comparators don't change.

## Beyond the basics: version notes and next reads

`Comparator.comparing`, `thenComparing`, `reversed`, `nullsFirst`/`nullsLast` and `naturalOrder` are all Java 8\. `Comparator.comparingInt` is Java 8 too. `List.sort(Comparator)` is Java 8; before that you used `Collections.sort`. Nothing in this article changed between Java 17 and 21, apart from records needing 16 or later.

A few things I'd think about before this reaches a real service:

- **Sort once, near the edge.** Resorting a large list on every request is a quiet cost. Keep a sorted copy, or push the order into the database query.
- **`TreeMap` with a comparator is also a definition of equality.** Anything that should be one key in the map must compare as 0\. This is how `BigDecimal` prices and case-insensitive names surprise people.
- **Locale-aware sorting needs `Collator`.** `String.CASE_INSENSITIVE_ORDER` works for English-style data. For names with accents, use `java.text.Collator`. I didn't test `Collator` in this build, so check its Javadoc before you rely on it.
- **Mutable keys break sorted collections.** If you change `quantity` on an item that's already inside a `TreeSet` ordered by quantity, the set won't know. That's one more reason to keep items immutable, as in [how to create an immutable class in Java](https://ggorantala.dev/how-to-create-immutable-class-java/?ref=ggorantala.dev).

Next reads, some already live: [how to implement the strategy pattern](https://ggorantala.dev/how-to-implement-strategy-pattern-java/?ref=ggorantala.dev) (a comparator is a strategy), [how to use sealed classes](https://ggorantala.dev/how-to-use-sealed-classes-interfaces-java/?ref=ggorantala.dev), and [how to use Optional](https://ggorantala.dev/how-to-use-optional-java/?ref=ggorantala.dev) for the null-handling side. Coming in this series: [chaining comparators](https://ggorantala.dev/how-to-chain-comparators-java/?ref=ggorantala.dev) in more depth, [PriorityQueue and TreeMap](https://ggorantala.dev/how-to-use-priorityqueue-treemap-java/?ref=ggorantala.dev), and [collecting stream results](https://ggorantala.dev/how-to-collect-stream-results-java/?ref=ggorantala.dev).

## Six things to remember

- Make a class `Comparable` only when there's one obvious order, and compare only the fields that identify the object.
- Put every other order in a named `Comparator` constant, built with `Comparator.comparing` and `thenComparing`.
- Never subtract in a comparator. Use `Integer.compare` or `comparingInt`.
- Wrap nullable keys with `nullsFirst` or `nullsLast`.
- A tie in a sorted set or map means "same item". End the chain with a unique field if that's not what you want.
- Test symmetry and transitivity yourself. The JDK throws the contract error only sometimes.

## Questions people ask about Comparable and Comparator

**What's the difference between Comparable and Comparator in Java?** `Comparable` is implemented by the class being sorted and gives it one natural order through `compareTo`. `Comparator` is a separate object with a `compare(a, b)` method, and you can have as many as you like. Use `Comparable` for the order that's obvious and a `Comparator` for everything else.

**How do I sort a list by multiple fields in Java?** Build a comparator with `Comparator.comparing(first).thenComparing(second)` and pass it to `list.sort`. Each later comparator runs only when all the earlier ones returned 0\. End with a unique field, such as an ID, so the order is deterministic.

**Why does my TreeSet remove elements?** A `TreeSet` decides what counts as a duplicate by `compareTo` or the comparator, and not by `equals`. If your comparator returns 0 for two different objects, the second one is never added. Add the missing fields to the comparator.

**How do I sort in descending order?** Call `.reversed()` on the comparator, or use `Comparator.reverseOrder()` for natural order. With `Comparator.comparing(...)` and a lambda, `.reversed()` can lose the type, so use a method reference.

## References

- [java.lang.Comparable, Java SE 21 API](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Comparable.html?ref=ggorantala.dev)
- [java.util.Comparator, Java SE 21 API](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Comparator.html?ref=ggorantala.dev)
- [java.util.TreeSet, Java SE 21 API](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/TreeSet.html?ref=ggorantala.dev)
- [JEP 395: Records](https://openjdk.org/jeps/395?ref=ggorantala.dev)