List vs. Set in Java: three places the contract splits
Both List and Set are Collections, so the temptation is to treat them as interchangeable bags of elements. They stop being interchangeable the moment you ask the two different questions those two interfaces are actually built around: what is at position 3? versus is this thing in there? The first question only a list can answer; the second question is what a set optimizes for, at the cost of collapsing duplicates.
Let’s walk the boundaries one at a time, with the same data fed into both.
Duplicates are where the contract splits
The Set interface defines itself as a collection that contains no duplicate elements (per the JDK 21 Set javadoc), while List happily stores the same object twice. The most visible consequence is size: feed one list and one set the exact same stream of elements and watch them diverge.
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
public class Duplicates {
public static void main(String[] args) {
List<String> seen = List.of("apple", "banana", "cherry", "banana", "apple");
List<String> list = new ArrayList<>(seen);
Set<String> set = new LinkedHashSet<>(seen);
System.out.println("source elements : " + seen);
System.out.println("list : " + list + " (size " + list.size() + ")");
System.out.println("set : " + set + " (size " + set.size() + ")");
System.out.println();
System.out.println("list.add(\"banana\") -> " + list.add("banana"));
System.out.println("set .add(\"banana\") -> " + set.add("banana"));
System.out.println();
System.out.println("list : " + list + " (size " + list.size() + ")");
System.out.println("set : " + set + " (size " + set.size() + ")");
}
}
(I used LinkedHashSet here so the only thing that can differ between the two lines is duplication — ordering gets its own section below.)
The real output, run with javac Duplicates.java && java Duplicates on OpenJDK 21:
source elements : [apple, banana, cherry, banana, apple]
list : [apple, banana, cherry, banana, apple] (size 5)
set : [apple, banana, cherry] (size 3)
list.add("banana") -> true
set .add("banana") -> false
list : [apple, banana, cherry, banana, apple, banana] (size 6)
set : [apple, banana, cherry] (size 3)
Two things to read out of that. First, the set didn’t just display fewer elements — its size() is 3, so the duplicates were never stored, not filtered at print time. Second, add comes back with different booleans even though you asked the same thing of both: true on the list, false on the set. That return value is the set answering “did this call change me?” — add something already present and it changes nothing, so it reports false. A set’s add is effectively put if absent; a list’s is just append.
What does remove actually remove?
Now that duplicates exist, the semantics of the membership operations get interesting. In a list, remove(Object) takes out one occurrence — the first one — and the rest of the copies stay put. In a set, there is only ever one copy, so removal is all-or-nothing.
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
public class Membership {
public static void main(String[] args) {
List<String> list = new ArrayList<>(List.of("alpha", "beta", "alpha", "gamma"));
Set<String> set = new LinkedHashSet<>(list);
System.out.println("list: " + list);
System.out.println("set : " + set);
System.out.println();
System.out.println("list.remove(\"alpha\") -> " + list.remove("alpha"));
System.out.println("set .remove(\"alpha\") -> " + set.remove("alpha"));
System.out.println();
System.out.println("list: " + list);
System.out.println("set : " + set);
System.out.println();
System.out.println("list.contains(\"alpha\") -> " + list.contains("alpha"));
System.out.println("set .contains(\"alpha\") -> " + set.contains("alpha"));
System.out.println("set .containsAll(list) -> " + set.containsAll(list));
}
}
Actual output:
list: [alpha, beta, alpha, gamma]
set : [alpha, beta, gamma]
list.remove("alpha") -> true
set .remove("alpha") -> true
list: [beta, alpha, gamma]
set : [beta, gamma]
list.contains("alpha") -> true
set .contains("alpha") -> false
set .containsAll(list) -> false
Both remove calls return true — but the state they leave behind is different. The list still holds [beta, alpha, gamma] because it only deleted the first alpha; the set is down to [beta, gamma] because it had nothing else to keep. One call, two different worlds.
And that difference is visible in the very last line: the list still contains alpha, the set no longer does, so set.containsAll(list) is false. containsAll asks a pure membership question about the argument’s elements, and at this point the argument has an alpha the set can’t match. If you wrote code that assumed “list and set were built from the same elements, so their containment checks should agree”, this is the line that quietly breaks it.
Does the container remember how you put things in?
A list is indexed: you can always ask for the element at a position. A set simply has no positional access at all — there is no get(int) on the Set interface — so the only way to reach an element is by its identity. That pushes the question: if I do just iterate a set, do I get insertion order back?
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
public class Order {
public static void main(String[] args) {
List<String> source = List.of("kilo", "alpha", "zulu", "mike", "beta");
List<String> list = new ArrayList<>(source);
Set<String> hash = new HashSet<>(source);
Set<String> linked = new LinkedHashSet<>(source);
System.out.println("insertion : " + source);
System.out.println("ArrayList : " + list);
System.out.println("HashSet : " + hash);
System.out.println("LinkedHashSet: " + linked);
System.out.println();
System.out.println("list.get(0) : " + list.get(0));
System.out.println("list.get(2) : " + list.get(2));
}
}
Actual output:
insertion : [kilo, alpha, zulu, mike, beta]
ArrayList : [kilo, alpha, zulu, mike, beta]
HashSet : [kilo, mike, alpha, zulu, beta]
LinkedHashSet: [kilo, alpha, zulu, mike, beta]
list.get(0) : kilo
list.get(2) : zulu
Same five strings, three containers. The ArrayList and LinkedHashSet both replay insertion order exactly, but the HashSet comes out as [kilo, mike, alpha, zulu, beta] — mike has jumped to the front. That’s not a bug or a version quirk; the JDK 21 HashSet javadoc says outright that it “makes no guarantees as to the iteration order of the set; in particular, it does not guarantee that the order will remain constant over time.” The order you see is an artifact of where each string’s hash value lands in the backing table, and the contract is telling you not to build logic on top of it.
So which is the “normal” set in Java? If you just need membership tests, HashSet is the default reach — the same javadoc notes it offers constant-time add, remove, contains, and size given a well-behaved hash function. If you need set semantics and stable insertion-order iteration, LinkedHashSet is the one. And if you need list.get(2), there’s only one option: a List.
Takeaway
A List is an ordered, indexed, duplicate-allowing sequence — you address it by position. A Set is a membership test — you address it by identity, duplicates collapse on the way in, and HashSet won’t even preserve the order you came in. The moment you find yourself calling list.contains(x) in a loop, asking “is it in there?”, is the moment it’s worth asking whether a set was the right container from the start.