Collectors.toSet()

Collects a stream into a Set of distinct elements, and shows how to choose ordering and mutability instead of relying on defaults.

Signature
public static <T> Collector<T, ?, Set<T>> toSet()
  • Added in Java Java 8

Return value

Collector<T, ?, Set<T>>A collector that gathers the distinct elements, compared with equals and hashCode, into a Set. The type, mutability, serialisability and thread safety of the Set are not guaranteed; current JDKs return a HashSet.

Errors

Does not throw. Unlike toUnmodifiableSet (Java 10), it accepts a null element.

Collectors.toSet() collects the elements of a stream into a Set, keeping one copy of each value as decided by equals and hashCode. Use it when the result is used for membership checks or when duplicates are meaningless: the set of roles a user has, the distinct tags on a page of articles, the ids already processed in a batch.

Set<String> domains = emails.stream()
    .map(e -> e.substring(e.indexOf('@') + 1))
    .collect(Collectors.toSet());

No order, no promises

The documentation guarantees nothing about the set's type, mutability or order. In current JDKs it is a HashSet, so iteration order depends on hash codes and table size, not on the order of the stream. That is the mistake people make: a set that looks sorted in a test with three small values comes out in a different order in production, and a JSON array or a snapshot test changes. When order matters, choose it explicitly with toCollection:

  • Collectors.toCollection(LinkedHashSet::new): distinct values in first-seen order.
  • Collectors.toCollection(TreeSet::new): distinct values sorted, or pass () -> new TreeSet<>(comparator).

Mutability

You can add to the set toSet() returns today, but that is an implementation detail. If you plan to modify the result, ask for toCollection(HashSet::new). If it must not change, for example a set of allowed origins built at startup, use Collectors.toUnmodifiableSet() (Java 10). It rejects add and also throws on a null element, while unlike Set.of it quietly drops duplicates.

Equality decides what is a duplicate

Records get equals and hashCode from their components, so two records with the same values collapse into one. A plain class without those methods compares by identity, so every instance stays, even with identical fields. Comparing strings case-sensitively is another trap: [email protected] and [email protected] are two elements unless you normalise them in a map step first.

To remove duplicates while keeping a list, stream.distinct().toList() keeps encounter order and may read better than going through a set.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Collecting the distinct permission names from a user's roles to check access on each request.
  • Building the set of SKUs already imported so a re-run of a batch job skips them.
  • Gathering the unique tags across a page of articles to render a tag filter.

Frequently asked questions

Does Collectors.toSet keep the order of the stream?

No. It returns a HashSet in current JDKs and the specification promises no order at all. Use Collectors.toCollection(LinkedHashSet::new) for first-seen order or Collectors.toCollection(TreeSet::new) for sorted order.

How do I collect a stream into a TreeSet or LinkedHashSet?

Use Collectors.toCollection with a constructor reference: stream.collect(Collectors.toCollection(TreeSet::new)). For a custom sort order pass a supplier such as () -> new TreeSet<>(String.CASE_INSENSITIVE_ORDER).

Is the set returned by Collectors.toSet mutable?

In current JDKs it is a HashSet you can change, but the documentation does not guarantee that. Use toCollection(HashSet::new) when you will modify it and Collectors.toUnmodifiableSet() (Java 10) when it must stay read-only.

More examples

A read-only set with toUnmodifiableSet

toUnmodifiableSet drops the repeated origin without complaint, then refuses any later change and any null element. It suits configuration that is built once at startup and shared across request threads.

Example · java
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;

void main() {
    List<String> origins = List.of("https://shop.example", "https://admin.example", "https://shop.example");

    Set<String> allowed = origins.stream().collect(Collectors.toUnmodifiableSet());
    IO.println(new TreeSet<>(allowed));

    try {
        allowed.add("https://evil.example");
    } catch (UnsupportedOperationException e) {
        IO.println("add rejected: " + e.getClass().getSimpleName());
    }

    try {
        Arrays.asList("a", null).stream().collect(Collectors.toUnmodifiableSet());
    } catch (NullPointerException e) {
        IO.println("null rejected: " + e.getClass().getSimpleName());
    }
}
// Output:
// [https://admin.example, https://shop.example]
// add rejected: UnsupportedOperationException
// null rejected: NullPointerException

Records collapse duplicates, plain classes do not

A record's equals and hashCode compare its components, so the two 'java' tags are one element. The class inherits identity equality from Object, so all three instances survive; give it equals and hashCode, or make it a record.

Example · java
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

record TagRecord(String name) {}

class TagClass {
    final String name;
    TagClass(String name) { this.name = name; }
}

void main() {
    Set<TagRecord> records = List.of("java", "api", "java").stream()
        .map(TagRecord::new)
        .collect(Collectors.toSet());
    Set<TagClass> objects = List.of("java", "api", "java").stream()
        .map(TagClass::new)
        .collect(Collectors.toSet());

    IO.println("records: " + records.size());
    IO.println("classes: " + objects.size());
}
// Output:
// records: 2
// classes: 3

Fast membership checks before filtering

Set.contains on a hash set takes constant time on average, while List.contains scans the whole list for every product. Building the set once turns a nested loop into a single pass, which matters when both lists come from large queries.

Example · java
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

record Product(String sku, String name) {}
record Discontinued(String sku) {}

void main() {
    List<Discontinued> discontinued = List.of(new Discontinued("TEE-M"), new Discontinued("CAP-1"));
    List<Product> catalogue = List.of(
        new Product("MUG-1", "Mug"), new Product("TEE-M", "T-shirt"),
        new Product("PAN-2", "Pan"), new Product("CAP-1", "Cap"));

    Set<String> gone = discontinued.stream()
        .map(Discontinued::sku)
        .collect(Collectors.toSet());

    List<String> onSale = catalogue.stream()
        .filter(p -> !gone.contains(p.sku()))
        .map(Product::name)
        .toList();
    IO.println(onSale);
}
// Output:
// [Mug, Pan]

Discussion

  • Be the first to comment on this lesson.