Skip to content
Preptima
mediumConceptCodingMidSeniorStaff

Sorting a list throws IllegalArgumentException saying the comparison method violates its general contract. What did the comparator get wrong?

The sort detected that your comparator contradicted itself partway through a merge. The usual causes are a subtraction that overflows, an ordering that is not transitive, and a sort key that changes while the sort is running.

5 min readUpdated 2026-07-29Target archetype: Big Tech, Enterprise Captive
Practice answering out loud

What the interviewer is scoring

  • Does the candidate treat the exception as detection of a pre-existing bug rather than as a defect in the sort
  • Whether the contract is stated as antisymmetry, transitivity and consistency instead of "compare must be correct"
  • That subtracting two ints is identified as an overflow bug rather than as a style preference
  • Can they explain why the same comparator sorted a shorter list without complaining
  • Whether a mutable sort key is recognised as making the comparison non-deterministic mid-sort

Answer

What the exception is telling you, and what it is not

Since Java 7, Arrays.sort on an object array — and therefore Collections.sort and List.sort — uses TimSort. TimSort scans the input for already-ordered runs, extends short ones with a binary insertion sort, and merges the runs pairwise. The merge step relies on invariants derived from earlier comparisons: having established that one run's head is greater than another's, it advances one side without re-asking. If a later comparison contradicts what an earlier one said, the merge walks off the end of a run, and the implementation notices and throws IllegalArgumentException with the message about the general contract.

The exception is therefore a report, not a cause. The comparator was already wrong before the sort ran, and it has probably been producing quietly wrong orderings everywhere else it is used — in a TreeMap, a PriorityQueue, a binary search — none of which merge and none of which will complain. Reaching for the legacy merge sort system property suppresses the message and leaves the incorrect ordering in place.

The three clauses a comparator has to satisfy

The contract is small enough to check by hand. Antisymmetry: compare(x, y) and compare(y, x) must have opposite signs for every pair, so a comparator may not report that each of two elements precedes the other. Transitivity: if x precedes y and y precedes z, then x must precede z, and the same must hold for the equality the comparator induces, so if compare(x, y) is zero then x and y must compare identically against every third element. Consistency: the answer for a given pair must not change during the sort.

Almost every real violation is one of these three, and each has a characteristic shape worth recognising on sight.

Overflow in a subtraction

The most common cause by a distance is a comparator written as a difference.

// Broken: the subtraction overflows for large magnitudes, so a large
// positive minus a large negative wraps and comes back negative.
Comparator<Account> byBalance = (a, b) -> a.balanceCents() - b.balanceCents();

// Correct: comparingInt delegates to Integer.compare, which branches
// rather than subtracting, so it cannot overflow.
Comparator<Account> fixed = Comparator.comparingInt(Account::balanceCents);

With values near the extremes of int, the subtraction wraps and antisymmetry breaks for exactly the pairs that wrap. That is why this bug can hide for years in a dataset of small numbers and surface the day somebody starts storing an amount in the smallest currency unit rather than in pounds. The same trap applies to a long difference narrowed to int, and to any comparator derived from a hash code, since hash codes routinely span the whole range.

An ordering that is not transitive

The second shape is a comparator that mixes criteria in a way that has no consistent total order. A rule such as "if two records are within a tolerance of each other treat them as equal, otherwise order by value" is intransitive by construction: two records can each be within tolerance of a middle one and outside tolerance of each other, so the comparator calls both pairs equal and the outer pair unequal. Layered rules do the same thing when a tie-break is applied to some pairs and not others, or when the comparator branches on the runtime type of its arguments so the answer depends on which one arrives first.

The fix is to build the order out of a chain of total orders, each applied to every pair, with Comparator.comparing followed by thenComparing. A chain of total orders is total by construction, and the resulting code makes the precedence of the criteria visible where a hand-written cascade of if statements hides it.

A key that changes while the sort runs

The third shape is consistency, and it is the one people misdiagnose. If the sort key is a mutable field and another thread mutates it between two comparisons, or the list itself is being written to concurrently, the comparator answers differently for the same pair and the merge invariant breaks even though the comparison logic is correct. Check for this before rewriting the comparator, because sorting a collection another thread is modifying is a race whatever the comparator does. Sort a defensive copy, or derive the key once into an immutable value and sort on that.

The same reasoning rules out comparators that consult anything ambient. One that reads a cache, a clock or a collator whose configuration can be changed at runtime is not a pure function of its two arguments, and the sort assumes that it is.

Why a shorter list gets away with it

The check exists only inside the merge. TimSort sorts inputs below a small threshold — 32 elements in the current implementation — with a binary insertion sort and no merge at all, so a broken comparator on a short list produces a wrong order silently and throws nothing. This detail is what separates a diagnosis from a guess, because it explains the complaint that the code passes its unit test and fails in production: the unit test sorted five rows. It also explains why the exception can appear after a data-volume increase with no change to the comparator at all. The input simply became long enough to reach a code path that checks.

The consequence for testing is that a comparator deserves a property test rather than an example test. Generate a few hundred values, including the extremes of the numeric range and duplicates on each tie-break field, then assert antisymmetry over pairs and transitivity over triples directly. That catches the overflow and the intransitive tie-break where they are cheap to fix, rather than in the log of a batch job that stopped halfway.

The exception is the sort telling you that your comparator answered two questions inconsistently. Fix the ordering — usually by replacing a subtraction with Integer.compare and a hand-rolled cascade with a comparing/thenComparing chain — rather than reaching for the flag that turns the check off.

Likely follow-ups

  • Why does a TreeMap given the same broken comparator lose entries instead of throwing?
  • What does it mean for a sort to be stable, and which sorts in java.util keep that property?
  • Comparator.comparing on a field that is sometimes null throws. How do you order nulls without a null check in every comparator?
  • What is -Djava.util.Arrays.useLegacyMergeSort=true for, and why is it not a fix?

Related questions

Further reading

collectionssortingcomparatortimsortobject-semantics