Java Tutorial
🔍

Java Stream distinct() Method

Java Stream distinct() Method

distinct() is the intermediate operation that removes duplicate elements from a stream, keeping only the first occurrence of each one and preserving the order everything else already had. It decides whether two elements are duplicates using equals() and hashCode() — the exact same contract HashSet relies on internally — which means distinct()'s correctness for custom objects depends entirely on whether those two methods have actually been overridden on the class involved.

What Is distinct()?

distinct() takes no arguments — there is no way to plug in a custom comparison rule the way sorted(Comparator) allows. It relies exclusively on equals() and hashCode() to decide whether two elements count as the same.

It is a stateful operation, like sorted(), but the two differ in an important way. sorted() must buffer the entire stream before it can produce even its first result, because an element's correct position depends on every other element in the stream. distinct() only needs to remember what it has already seen — it can emit each element the instant it determines that element is not a duplicate, without needing to know what comes later. Both are stateful; only sorted() is fully buffering.

Why distinct() Was Introduced

Removing duplicates while preserving order used to mean a loop, a HashSet used purely to track what had already been seen, and a second list built up alongside it to hold the ordered result.

1// File: BeforeDistinct.java 2import java.util.*; 3 4public class BeforeDistinct { 5 public static void main(String[] args) { 6 List<String> viewedSkus = List.of("SKU-1", "SKU-2", "SKU-1", "SKU-3", "SKU-2"); 7 8 Set<String> seen = new HashSet<>(); 9 List<String> uniqueSkus = new ArrayList<>(); 10 for (String sku : viewedSkus) { 11 if (seen.add(sku)) { 12 uniqueSkus.add(sku); 13 } 14 } 15 16 System.out.println(uniqueSkus); 17 } 18}
Output:
[SKU-1, SKU-2, SKU-3]

distinct() keeps the same behavior — first occurrence wins, order is preserved — without the second collection tracking what has already been added.

1// File: AfterDistinct.java 2import java.util.*; 3import java.util.stream.*; 4 5public class AfterDistinct { 6 public static void main(String[] args) { 7 List<String> viewedSkus = List.of("SKU-1", "SKU-2", "SKU-1", "SKU-3", "SKU-2"); 8 9 List<String> uniqueSkus = viewedSkus.stream() 10 .distinct() 11 .collect(Collectors.toList()); 12 13 System.out.println(uniqueSkus); 14 } 15}
Output:
[SKU-1, SKU-2, SKU-3]

Both versions produce the exact same three-element result. The stream version has no manually tracked HashSet sitting alongside the actual result list.

Syntax

distinct() works perfectly on types with a correct equals()/hashCode() implementation, and silently produces the wrong count on types without one.

1// File: DistinctSyntaxForms.java 2import java.util.*; 3import java.util.stream.*; 4 5public class DistinctSyntaxForms { 6 7 static class ProductWithoutEquals { 8 private final String sku; 9 ProductWithoutEquals(String sku) { this.sku = sku; } 10 } 11 12 public static void main(String[] args) { 13 List<Integer> numbers = List.of(3, 1, 3, 2, 1, 4); 14 15 // distinct() on primitively-comparable types works exactly as expected 16 List<Integer> uniqueNumbers = numbers.stream() 17 .distinct() 18 .collect(Collectors.toList()); 19 20 // Two ProductWithoutEquals instances built from the same sku are still 21 // considered different, because the class never overrides equals()/hashCode() 22 List<ProductWithoutEquals> products = List.of( 23 new ProductWithoutEquals("SKU-1"), 24 new ProductWithoutEquals("SKU-1") 25 ); 26 long distinctCount = products.stream().distinct().count(); 27 28 System.out.println("Unique numbers: " + uniqueNumbers); 29 System.out.println("Distinct count without equals/hashCode: " + distinctCount); 30 } 31}
Output:
Unique numbers: [3, 1, 2, 4]
Distinct count without equals/hashCode: 2

Common Use Cases

Deduplicating Records, Which Get equals() and hashCode() for Free

A Java record automatically generates equals() and hashCode() based on all of its components, which is exactly what distinct() needs to work correctly with no extra code.

1// File: DistinctWithRecordExample.java 2import java.util.*; 3import java.util.stream.*; 4 5public class DistinctWithRecordExample { 6 record Product(String sku, String name) {} 7 8 public static void main(String[] args) { 9 List<Product> products = List.of( 10 new Product("SKU-1", "Mouse"), 11 new Product("SKU-1", "Mouse"), 12 new Product("SKU-2", "Keyboard") 13 ); 14 15 List<Product> uniqueProducts = products.stream() 16 .distinct() 17 .collect(Collectors.toList()); 18 19 System.out.println(uniqueProducts.size()); 20 } 21}
Output:
2

Deduplicating by a Derived Key

distinct() has no built-in way to compare just one field of an object — mapping to that field first turns the problem into an ordinary, whole-value comparison distinct() already handles.

1// File: DistinctByDerivedKeyExample.java 2import java.util.*; 3import java.util.stream.*; 4 5public class DistinctByDerivedKeyExample { 6 record ViewEvent(String sku, String timestamp) {} 7 8 public static void main(String[] args) { 9 List<ViewEvent> views = List.of( 10 new ViewEvent("SKU-1", "10:01"), 11 new ViewEvent("SKU-2", "10:02"), 12 new ViewEvent("SKU-1", "10:05") 13 ); 14 15 // distinct() would treat all three as different, since the timestamps 16 // differ - mapping to just the sku first lets distinct() dedupe on that 17 List<String> uniqueSkusViewed = views.stream() 18 .map(ViewEvent::sku) 19 .distinct() 20 .collect(Collectors.toList()); 21 22 System.out.println(uniqueSkusViewed); 23 } 24}
Output:
[SKU-1, SKU-2]

Confirming Encounter Order Is Preserved

distinct() on an ordered stream is documented to keep elements in their original relative order — it removes elements, it never reorders the ones that survive.

1// File: DistinctOrderPreservedExample.java 2import java.util.*; 3import java.util.stream.*; 4 5public class DistinctOrderPreservedExample { 6 public static void main(String[] args) { 7 List<String> tags = List.of("sale", "new", "sale", "trending", "new", "featured"); 8 9 List<String> uniqueTags = tags.stream() 10 .distinct() 11 .collect(Collectors.toList()); 12 13 System.out.println(uniqueTags); 14 } 15}
Output:
[sale, new, trending, featured]

Combining distinct() and sorted()

The final result is identical regardless of which order the two are chained in — but running distinct() first, when duplicates are expected, means sorted() has fewer elements left to order afterward.

1// File: DistinctWithSortedExample.java 2import java.util.*; 3import java.util.stream.*; 4 5public class DistinctWithSortedExample { 6 public static void main(String[] args) { 7 List<Integer> ratings = List.of(4, 2, 4, 5, 2, 3); 8 9 List<Integer> distinctThenSorted = ratings.stream() 10 .distinct() 11 .sorted() 12 .collect(Collectors.toList()); 13 14 List<Integer> sortedThenDistinct = ratings.stream() 15 .sorted() 16 .distinct() 17 .collect(Collectors.toList()); 18 19 System.out.println("distinct then sorted: " + distinctThenSorted); 20 System.out.println("sorted then distinct: " + sortedThenDistinct); 21 } 22}
Output:
distinct then sorted: [2, 3, 4, 5]
sorted then distinct: [2, 3, 4, 5]

Real-World Example

An e-commerce app's "Recently Viewed" widget shows each product exactly once, positioned according to the most recent time it was viewed, capped at a fixed number of items. A shopper who views the same product twice in one session should see it appear once, in the position matching their latest view — not their first one. Since distinct() always keeps the first occurrence it encounters, feeding it the browsing history in plain chronological order would keep each product's earliest view instead, which is the opposite of what a "recently viewed" feature is supposed to show.

1// File: ViewEvent.java 2import java.util.*; 3 4public record ViewEvent(String sku, String name, int sequence) { 5 6 // Two view events represent the same product view for widget purposes 7 // when they share a sku - sequence and name are ignored deliberately, 8 // overriding the record's normally auto-generated equals()/hashCode() 9 @Override 10 public boolean equals(Object other) { 11 if (this == other) return true; 12 if (!(other instanceof ViewEvent that)) return false; 13 return sku.equals(that.sku); 14 } 15 16 @Override 17 public int hashCode() { 18 return Objects.hash(sku); 19 } 20}
1// File: RecentlyViewedService.java 2import java.util.*; 3import java.util.stream.*; 4 5public class RecentlyViewedService { 6 7 public List<ViewEvent> buildWidget(List<ViewEvent> chronologicalHistory, int maxItems) { 8 List<ViewEvent> mostRecentFirst = new ArrayList<>(chronologicalHistory); 9 Collections.reverse(mostRecentFirst); 10 11 return mostRecentFirst.stream() 12 .distinct() 13 .limit(maxItems) 14 .collect(Collectors.toList()); 15 } 16}
1// File: RecentlyViewedDemo.java 2import java.util.*; 3 4public class RecentlyViewedDemo { 5 public static void main(String[] args) { 6 List<ViewEvent> history = List.of( 7 new ViewEvent("SKU-1", "Wireless Mouse", 1), 8 new ViewEvent("SKU-2", "Keyboard", 2), 9 new ViewEvent("SKU-3", "Monitor", 3), 10 new ViewEvent("SKU-1", "Wireless Mouse", 4), 11 new ViewEvent("SKU-4", "Webcam", 5) 12 ); 13 14 RecentlyViewedService service = new RecentlyViewedService(); 15 List<ViewEvent> widget = service.buildWidget(history, 3); 16 17 widget.forEach(event -> System.out.println(event.sku() + " - " + event.name())); 18 } 19}
Output:
SKU-4 - Webcam
SKU-1 - Wireless Mouse
SKU-3 - Monitor

Reversing the history before distinct() runs is what makes the surviving SKU-1 entry the one from sequence 4, the shopper's more recent view, rather than the stale one from sequence 1. A mistake that appears often in fresher pull requests is calling distinct() directly on the chronological history without reversing it first, which quietly keeps each product's earliest view instead of its most recent one — a widget built that way never updates a product's position no matter how many times the shopper revisits it afterward.

Combining distinct() With Other Features

distinct() relies entirely on equals() and hashCode(), the exact same contract HashSet and HashMap keys depend on — understanding one explains the other. The Stream API has no built-in "distinct by one field" operation, so map() before distinct(), or a deliberately narrowed equals()/hashCode() override like the one on ViewEvent, are the two standard ways to dedupe by only part of an object's state. distinct() and sorted() are both stateful, but distinct() can emit results incrementally as it processes each element, while sorted() cannot produce anything until it has seen the entire stream.

Best Practices

Override equals() and hashCode() together, consistently, on any custom class that will ever be deduplicated with distinct(). Relying on the default identity-based comparison almost always produces the wrong result silently, with no exception raised to catch the mistake.

When only part of an object's state should determine uniqueness, either map() to just that key before calling distinct(), or deliberately override equals()/hashCode() to reflect that narrower definition of "the same," exactly as the recently-viewed products example does.

Reorder the input before distinct() when the most recent or last occurrence should survive instead of the default behavior of keeping the first one encountered.

Run distinct() before sorted() when both are needed and duplicates are actually expected in the data, since removing duplicates first leaves sorted() with fewer elements to order.

Common Mistakes

Assuming distinct() keeps the last occurrence of a duplicate, the way a Map.put() with a repeated key would overwrite the earlier value, is a common but incorrect expectation. distinct() always keeps the first occurrence and silently discards anything that comes after it.

1// File: DistinctKeepsFirstMistake.java 2import java.util.*; 3import java.util.stream.*; 4 5public class DistinctKeepsFirstMistake { 6 record Score(String player, int value) { 7 @Override 8 public boolean equals(Object other) { 9 if (this == other) return true; 10 if (!(other instanceof Score that)) return false; 11 return player.equals(that.player); 12 } 13 14 @Override 15 public int hashCode() { 16 return player.hashCode(); 17 } 18 } 19 20 public static void main(String[] args) { 21 List<Score> scores = List.of( 22 new Score("Ananya", 40), 23 new Score("Ananya", 95) 24 ); 25 26 // distinct() keeps the FIRST occurrence, not the last - the score 27 // of 95 recorded second is silently discarded here 28 List<Score> result = scores.stream().distinct().collect(Collectors.toList()); 29 30 System.out.println(result.get(0).player() + " -> " + result.get(0).value()); 31 } 32}
Output:
Ananya -> 40

Overriding equals() without also overriding hashCode() breaks distinct() in a way that is easy to miss, because the code compiles fine and simply produces the wrong count with no warning at all.

1// File: EqualsWithoutHashCodeMistake.java 2import java.util.*; 3import java.util.stream.*; 4 5public class EqualsWithoutHashCodeMistake { 6 7 static class Coupon { 8 private final String code; 9 Coupon(String code) { this.code = code; } 10 11 // equals() is overridden, but hashCode() is not - this violates the 12 // contract that equal objects must produce the same hash code, 13 // and distinct() (like HashSet) relies on hashCode() internally 14 @Override 15 public boolean equals(Object other) { 16 if (this == other) return true; 17 if (!(other instanceof Coupon that)) return false; 18 return code.equals(that.code); 19 } 20 } 21 22 public static void main(String[] args) { 23 List<Coupon> coupons = List.of(new Coupon("SAVE10"), new Coupon("SAVE10")); 24 25 long distinctCount = coupons.stream().distinct().count(); 26 27 System.out.println("Distinct count with equals() but no hashCode(): " + distinctCount); 28 System.out.println("Without a matching hashCode() override, distinct() cannot rely on equals()"); 29 System.out.println("alone - the equals()/hashCode() contract has to be honored together"); 30 } 31}
Output:
Distinct count with equals() but no hashCode(): 2
Without a matching hashCode() override, distinct() cannot rely on equals()
alone - the equals()/hashCode() contract has to be honored together

Treating distinct() as free of any real cost is a subtler mistake worth being aware of. Even though it never reorders elements or does anything as visibly expensive as sorting, distinct() still has to track every unique value it has seen so far in auxiliary memory, and that tracking grows with the number of distinct elements in the stream, not the total number processed.

Interview Questions

Q1. What does distinct() do, and what does it use to determine whether two elements are the same?

distinct() removes duplicate elements from a stream, keeping the first occurrence of each one and preserving the original relative order of whatever survives. It determines whether two elements are duplicates using equals() and hashCode(), exactly the same contract HashSet relies on for its own uniqueness checks. Interviewers commonly follow this question by asking what happens for a custom class, to see if the candidate connects distinct() back to that contract rather than treating it as magic.

Q2. What happens if you call distinct() on a stream of custom objects that don't override equals() and hashCode()?

The default Object.equals() and Object.hashCode() implementations are used, which compare objects by identity rather than by their field values. Two separate instances built with identical field values are still treated as different, so distinct() fails to remove what should logically be considered duplicates — silently, with no exception or warning, which is exactly what makes this mistake dangerous in real code.

Q3. Does distinct() keep the first or the last occurrence of a duplicate?

The first occurrence. Once distinct() has accepted an element as unique, every later element it considers equal to that one is discarded, regardless of whether the later element carries different or more up-to-date information. Anything relying on the most recent occurrence surviving, like a "recently viewed" widget, needs to reorder the input before distinct() runs.

Q4. Is distinct() a stateful or stateless intermediate operation, and how does that compare to sorted()?

distinct() is stateful, since deciding whether an element is a duplicate depends on everything seen before it. It differs from sorted(), which is also stateful but must buffer the entire stream before producing any result at all — distinct() can emit each non-duplicate element immediately, since it only needs to remember what has already passed through, not what comes next.

Q5. How would you deduplicate a stream of objects based on only one field, rather than the whole object?

Two approaches work. map() to just that field before calling distinct(), if the rest of the object's data is not needed afterward, or deliberately override equals() and hashCode() on the class to compare only that one field, which keeps the full object available downstream, exactly as the ViewEvent class in the recently-viewed example does.

Q6. Why is overriding equals() without also overriding hashCode() a problem for distinct()?

distinct() uses a hash-based mechanism internally, the same way HashSet does, which means it checks hashCode() first to narrow down which existing elements to compare an incoming element against, and only calls equals() among elements that already share a hash code. If two objects are equal according to equals() but produce different hash codes because hashCode() was never overridden, distinct() may never even reach the equals() check that would have identified them as duplicates, since they land in different internal buckets entirely.

FAQs

Is distinct() an intermediate or terminal operation?

Intermediate. It returns a new Stream, not a final result, and like every intermediate operation it does nothing on its own until a terminal operation such as collect or forEach actually runs the pipeline.

Does distinct() work on a stream of primitives like IntStream?

Yes. IntStream, LongStream, and DoubleStream each provide their own distinct(), comparing primitive values directly by numeric equality rather than relying on equals()/hashCode(), since primitives have no such methods to call.

Does distinct() preserve the original order of elements?

Yes, for an ordered stream. Stream.distinct() is documented to preserve the encounter order of the surviving elements — it only removes duplicates, it never reorders anything that remains.

Can distinct() be used with a custom Comparator like sorted() can?

No. distinct() has no overload accepting a Comparator or any other custom comparison rule — it always uses equals() and hashCode(). Deduplicating by a custom rule requires either mapping to a derived key first or overriding equals()/hashCode() on the class itself.

Does distinct() modify the original list the stream was built from?

No. distinct() produces a new stream and never touches its source, the same guarantee every intermediate stream operation provides.

How does distinct() behave on a parallel stream?

The final set of surviving elements is the same regardless of whether the stream is sequential or parallel, but the ordering guarantee weakens — distinct() on an unordered or parallel stream makes no guarantee about which duplicate happens to be kept, only that exactly one of each equal group survives.

What is the time complexity of distinct(), roughly speaking?

On average, close to linear in the number of elements, since it relies on hash-based lookups similar to HashSet, where checking whether a value has already been seen is typically a constant-time operation. Like any hash-based structure, this degrades if many elements produce colliding hash codes, though that is uncommon with a reasonably well-distributed hashCode() implementation.

Summary

distinct() removes duplicates the same way HashSet would, using equals() and hashCode(), keeping the first occurrence of each unique element and leaving everything else in its original relative order. It is stateful like sorted(), but it does not need to buffer the whole stream before producing output — it only needs to remember what it has already seen.

The habit worth carrying forward is treating equals() and hashCode() as the real API distinct() is built on, not an implementation detail to ignore. A missing or inconsistent override produces no error at all — just a quietly wrong count, exactly the kind of bug the coupon and score examples above were built to make visible. Collectors.toSet() and Collectors.groupingBy(), covered in the article on collect() and the dedicated grouping article, both lean on this exact same contract once a stream needs to become something other than a plain list.

What to Read Next