A Python set is a collection with two guarantees and one omission. Its members are unique and it answers membership questions quickly. What it does not give you is an order — and code that quietly depends on set order is the kind of bug that only shows up on someone else's machine.
What is a set and what does it guarantee?
Writing {"a", "b"} creates a set with two elements. Writing {"a", "a", "b"} creates a set with two elements as well, because duplicates collapse at the moment of construction.
tags = {"python", "python", "web", "web", "web"}
print(sorted(tags))
print(len(tags))
numbers = {1, 2, 3}
print(numbers)
There is one trap that catches every beginner at least once: {} is an empty dictionary, not an empty set. The empty set is set().
print(type({}))
print(type(set()))
print(set() == {})
The second guarantee is about how membership is checked. A set works out a number from each item and uses that number to place it, so testing whether an item is present does not require walking the whole collection. The size of the set barely changes the cost of the check, which is exactly what you want when you are testing thousands of items against a whitelist.
How do you add and remove items from a set?
Four methods, and the difference between remove and discard is the one that matters.
skills = {"python"}
skills.add("sql")
skills.update({"docker", "git"})
print(sorted(skills))
skills.discard("cobol")
print(sorted(skills))
try:
skills.remove("cobol")
except KeyError as exc:
print("KeyError:", exc)
remove raises when the item is not there; discard quietly does nothing. Use remove when the absence is a bug worth shouting about, and discard when it is an expected outcome.
pop removes an element and hands it back, but a set has no order, so which element comes off is not defined. Never write code that depends on it. The one place it is safe is when the set has exactly one member.
last = {"only one"}
print(last.pop())
print(last)
Because a set stores items by hash, everything in it must be hashable. Strings, numbers, tuples and frozen sets qualify; lists and dictionaries do not.
try:
broken = {[1, 2]}
except TypeError as exc:
print("TypeError:", exc)
How do you do set math and compare sets?
Sets support the four operations you would draw on paper, using symbols that match their mathematical names.
| Operator | Meaning | Method form |
|---|---|---|
| | union — everything in either set | a.union(b) |
& | intersection — everything in both | a.intersection(b) |
- | difference — in a but not b | a.difference(b) |
^ | symmetric difference — in one but not both | a.symmetric_difference(b) |
a = {1, 2, 3}
b = {3, 4, 5}
print(a | b)
print(a & b)
print(a - b)
print(a ^ b)
print(a.union(b))
print(a.intersection(b))
print(a.difference(b))
print(a.symmetric_difference(b))
Each operator also has an in-place form — |=, &=, -=, ^= — which modifies the left-hand set instead of creating a new one. That is the same distinction you saw with list.sort() versus sorted(): the in-place form returns None, so do not assign its result.
a = {1, 2}
a |= {3}
print(a)
a &= {2, 3}
print(a)
Beyond the arithmetic, sets answer containment questions directly.
small = {1, 2}
big = {1, 2, 3, 4}
print(small.issubset(big))
print(big.issuperset(small))
print(small.isdisjoint({9, 10}))
print(small <= big)
print(small < big)
issubset allows equality, while <= means the same thing. The strict < asks for proper containment, so small < big is True while big < big is False. isdisjoint is the quick "do these two have nothing in common" test, and it short-circuits on the first match.
These comparisons are the natural way to check whether two permission sets differ. A user has access to a resource when their permissions intersect the resource's required permissions.
required = {"read", "write"}
granted = {"read", "audit"}
print(sorted(required & granted))
print(required.issubset(granted))
When should you use a frozenset?
frozenset is the immutable version of a set. You cannot add to it or remove from it, which means it is hashable, which means you can use it as a dictionary key or put it inside another set.
permissions = frozenset({"read", "write"})
registry = {permissions: "editor"}
print(registry[permissions])
print(frozenset({1, 2}) == frozenset({2, 1}))
Two sets are equal when they contain the same elements, whatever order they were built in. That is why the last line is True even though the literals list their items differently.
Use a frozenset when the collection is a constant you want to pass around safely. Nobody can accidentally add an item to it, and it works as a key.
How do you build a set, and what happens to the order?
Almost anything iterable becomes a set, and the conversion is where duplicates disappear.
print(sorted(set(["python", "web", "python"])))
print(sorted(set("mississippi")))
print(sorted(set({"a": 1, "b": 2})))
print(sorted(set(range(5))))
Two of those deserve a note. set("mississippi") gives you the distinct characters, because iterating a string yields its characters. set(some_dict) gives you the keys, because iterating a dictionary yields its keys.
Converting a list to a set is also the standard deduplication trick, at the price of losing the order. If you need both uniqueness and the original order, build a dictionary instead: list(dict.fromkeys(items)) keeps the first occurrence of each value and keeps it in place.
Now the reason the order disappears. A set stores its members by hash value, not by position, so the order you see when you print a set is a side effect of the internal layout — and it changes between runs when Python randomises string hashing.
words = ["delta", "alpha", "charlie", "bravo"]
grouped = set(words)
print(sorted(grouped))
print(list(grouped) == list(grouped))
That last line is worth understanding rather than fearing. Asking for the same set twice in a row can give different lists, because the iteration order of a set is not part of its contract. If your program needs a repeatable order, either sort the result or use a dict, which does guarantee insertion order.
When should you use a set instead of a list?
The deciding question is whether you keep asking "is this thing present?". With a list, each question means walking the items until a match turns up. With a set, the answer comes back without scanning.
blocked = {"spammer", "bot"}
for user in ["ada", "bot", "grace"]:
print(user, "blocked" if user in blocked else "allowed")
Membership is the strongest argument for a set, but there is a second one: expressing uniqueness. A list that is supposed to contain distinct items needs you to check for duplicates every time you add one. A set does it for free, and its len() tells you the number of distinct items without any extra work.
The cost of a set is that it is unordered and its members must be hashable. Those two constraints are what you give up in exchange for speed and uniqueness.
Set operations reference
| Goal | Code |
|---|---|
| Empty set | set() |
| Add one item | s.add(x) |
| Remove, error if absent | s.remove(x) |
| Remove, ignore if absent | s.discard(x) |
| Membership | x in s |
| Union / intersection | a | b / a & b |
| Difference / symmetric difference | a - b / a ^ b |
| Containment | a.issubset(b), a <= b |
| No overlap | a.isdisjoint(b) |
| Immutable set | frozenset(...) |
Try it yourself: take two lists of student names that share some members, convert them to sets, and use &, |, - and ^ to find the students in both, in either, only in the first, and in exactly one. Try to add a list to a set and read the error. Then build a set of characters from a sentence, remove the vowels with a difference against another set, and print the result sorted so the output is stable.