Implement Java’s compareTo() the right way

Java’s Comparable#compareTo() looks simple, but small mistakes can create issues that are difficult to find — especially with sorted collections like TreeSet and TreeMap, which rely on ordering to determine uniqueness.

Why do you need compareTo()?

Java’s Comparable interface defines a method for comparing two objects. This can be used to sort your objects in their natural order. Typical examples for natural ordering are

  • String → alphabetical order
  • LocalDate → chronological order

In business code, natural ordering should represent the most common and expected meaning of “sorted”. For example, sorting line items by position in an order is usually what humans expect.

How to implement compareTo()

A correct compareTo() must follow these rules:

  • Anti-symmetric: signum(a.compareTo(b)) == -signum(b.compareTo(a))
  • Transitive: If a > b and b > c, then a > c
  • Consistent: If a.compareTo(b) == 0, they are considered equal in ordering

Also: it is recommended (but not required) that compareTo() is consistent with equals(). We will see why that is important later.

So let’s take the same domain example as before: an order with multiple items. Imagine a type representing a line item in an order:

  • id → database identity (unique)
  • position → business ordering inside the order (1, 2, 3, …)

We want:

  • Natural ordering: by position (ascending)
  • Alternative ordering: by id (ascending)

A naive implementation could look like this:

public record LineItem(long id, int position) implements Comparable<LineItem> {
    @Override
    public int compareTo(LineItem o) {
        return Integer.compare(this.position, o.position); // risky: compareTo==0 for different ids
    }
}
Fig. 1: Naive implementation of `compareTo()` for `LineItem`

Looks reasonable — but this innocent one-liner can silently lose data from your collections without a single exception or warning. Can you spot the flaw?

The TreeSet bug: “missing elements”

TreeSet does not use equals() to decide duplicates. It uses ordering: if compareTo() returns 0, the element is treated as a duplicate.

import java.util.Set;
import java.util.TreeSet;

public class Demo {
    public static void main(String[] args) {
        Set<LineItem> set = new TreeSet<>();

        set.add(new LineItem(1001L, 1));
        set.add(new LineItem(1002L, 1)); // same position, different id
        set.add(new LineItem(1003L, 2));

        System.out.println(set);
        System.out.println("size = " + set.size());
    }
}
Fig. 2: TreeSet drops elements when compareTo() returns 0

With the broken compareTo(), the output will effectively be:

  • One of the position=1 entries is dropped
  • size becomes 2 instead of 3

This is not a TreeSet bug. It is a compareTo implementation bug.

A correct implementation for natural ordering

If your natural ordering is “position first”, the safe version is:

public record LineItem(long id, int position) implements Comparable<LineItem> {
    @Override
    public int compareTo(LineItem other) {
        int byPosition = Integer.compare(this.position, other.position);
        if (byPosition != 0) return byPosition;

        // Tie-breaker: ensure a total order (important for TreeSet/TreeMap)
        return Long.compare(this.id, other.id);
    }
}
Fig. 3: Correct `compareTo()` with `id` tie-breaker for a total order

Why the tie-breaker matters

This makes ordering:

  1. sort by position
  2. if same position, sort by id

Now:

  • the ordering is total
  • TreeSet can safely store multiple items with the same position
  • you avoid accidental “duplicates”

Bonus: Use Comparator for alternative ordering

Natural ordering should reflect the business meaning. For LineItem, sorting by position is usually what humans expect. But you will often also need sorting by id (for stable output, debugging, or database work). That should be done with a Comparator, not by changing compareTo()

import java.util.Comparator;

public class LineItemOrderings {
    public static final Comparator<LineItem> BY_ID =
            Comparator.comparingLong(LineItem::id);

    public static final Comparator<LineItem> BY_POSITION_THEN_ID =
            Comparator.comparingInt(LineItem::position)
                      .thenComparingLong(LineItem::id);
}
Fig. 4: Comparators for alternative orderings

This way you can easily sort by different criteria:

import java.util.ArrayList;
import java.util.List;

public class SortingDemo {
    public static void main(String[] args) {
        List<LineItem> list = new ArrayList<>(List.of(
                new LineItem(2002L, 2),
                new LineItem(2001L, 1),
                new LineItem(2003L, 1)
        ));

        list.sort(null); // natural ordering (position, then id)
        System.out.println("Natural: " + list);

        list.sort(LineItemOrderings.BY_ID); // ordering by id
        System.out.println("By id:   " + list);
    }
}
Fig. 5: Sorting with natural order vs comparator

Common Pitfalls

A common design mistake is to make compareTo() sort by id just because id is unique. That is usually wrong because:

  • it does not represent business meaning
  • it surprises readers of the code
  • it breaks UI expectations (“why is position ignored?”)

Instead:

  • keep natural ordering for business meaning (position)
  • use comparators for alternative views (id)

Another common flaw is to make compareTo() inconsistent with equals(). Java recommends consistency:

if a.compareTo(b) == 0, then a.equals(b) should also be true

But it is not required. For records, equals() uses all components (id and position). If your compareTo() only compares position, it becomes inconsistent with equals(). This is often a source of subtle bugs in sorted collections like we explored before with the TreeSet. Adding the id tie-breaker solves that problem in a clean way.

Takeaways

  • compareTo() defines natural ordering, not “any ordering”.
  • Never implement comparison using subtraction (a - b): it overflows for large negative values and returns a wrong result silently.
  • If compareTo() == 0, sorted collections treat elements as duplicates.
  • For business ordering like position, add a tie-breaker (id) to create a total order.
  • Use explicit Comparators for alternative sorting (like ordering by id).

If you follow these rules, your ordering logic stays predictable, safe, and easy to maintain.