Python Sets: The Complete Guide
Everything about Python sets — creating them, why they're unordered and unique, add/update, the four ways to remove, all the set operations (union, intersection, difference), frozensets, and when to use a set instead of a list.
A set is an unordered collection of unique items. Those two words
carry the whole idea: sets automatically throw away duplicates, and they don’t
remember what order you added things in. In exchange you get blazing-fast
membership tests (x in my_set) and clean mathematical operations like union
and intersection.
This guide covers every basic concept with a runnable example for each. Outputs
are shown as # => ... comments.
The three defining properties
| Property | What it means |
|---|---|
| Unordered | No index positions — s[0] is an error. Print order may differ from insertion order. |
| Unique | Duplicates are silently dropped. |
| Mutable | You can add and remove items (but the items themselves must be immutable). |
thisset = {"apple", "banana", "cherry"}
print(thisset) # => {'banana', 'cherry', 'apple'} <- order not guaranteed!
dupes = {"apple", "banana", "apple", "cherry", "banana"}
print(dupes) # => {'banana', 'cherry', 'apple'} <- duplicates removed
print(len(dupes)) # => 3
Creating sets
s = {"apple", "banana", "cherry"} # literal (curly braces)
s = set(["apple", "banana"]) # from a list
s = set(("apple", "banana")) # from a tuple
s = set("hello") # => {'h', 'e', 'l', 'o'} (unique chars)
empty = set() # the ONLY way to make an empty set
empty = {} # WRONG: this is an empty DICT, not a set!
type({}) # => <class 'dict'>
type(set()) # => <class 'set'>
That last one is the classic trap: {} is an empty dict. Use set().
Why sets behave this way
All three properties fall out of how a set is stored. Internally a set is a hash table: when you add an item, Python computes its hash (a number derived from the item’s value) and uses that number to decide which memory slot to drop it into.
That single design choice explains everything:
- Unordered — items land wherever their hash points, not in the order you added them. There’s no “first” slot, so there’s no meaningful index.
- Unique — two equal items hash to the same slot, so the second one just overwrites the first. Deduplication isn’t a feature Python bolted on; it’s an unavoidable consequence of the storage.
- Hashable items only — if an item could change after being stored, its hash would change and Python could never find it again. That’s why mutable objects like lists are banned.
- Instant lookups — to check membership, Python hashes your item and looks in exactly one slot. It never scans the collection, which is why the check is just as fast on a million items as on ten.
Accessing items
You cannot access set items by index or key. This is the most common surprise coming from lists, and it follows directly from the hash-table storage above: items sit in hash-determined slots, so “position 0” doesn’t refer to anything stable. Even if Python let you ask for it, the answer could change the moment you added an unrelated item.
thisset = {"apple", "banana", "cherry"}
thisset[0] # TypeError: 'set' object is not subscriptable
# Instead, loop over it:
for x in thisset:
print(x) # banana / cherry / apple (order not guaranteed)
# ...or test membership (this is what sets are FOR - it's O(1) and instant):
print("banana" in thisset) # => True
print("banana" not in thisset) # => False
Adding items
thisset = {"apple", "banana", "cherry"}
thisset.add("orange") # add ONE item
print(thisset) # => {'orange', 'banana', 'cherry', 'apple'}
thisset.add("apple") # already present -> nothing happens, no error
print(len(thisset)) # => 4
# update() adds all items from another set (or any iterable):
tropical = {"pineapple", "mango", "papaya"}
thisset.update(tropical)
print(thisset)
# => {'papaya', 'cherry', 'pineapple', 'mango', 'banana', 'apple', 'orange'}
thisset.update(["kiwi", "grape"]) # works with lists/tuples too
add() takes one item; update() takes an iterable of items — the same
distinction as list append vs extend.
Removing items — four ways
thisset = {"apple", "banana", "cherry"}
# 1. remove() - raises KeyError if the item is missing
thisset.remove("banana")
print(thisset) # => {'cherry', 'apple'}
thisset.remove("banana") # KeyError: 'banana' <- it's already gone!
# 2. discard() - same, but NO error if missing (safe)
thisset = {"apple", "banana", "cherry"}
thisset.discard("banana")
print(thisset) # => {'cherry', 'apple'}
thisset.discard("orange") # not there -> no error, nothing happens
print(thisset) # => {'cherry', 'apple'}
# 3. pop() - removes and RETURNS an arbitrary item
thisset = {"apple", "banana", "cherry"}
x = thisset.pop()
print(x) # => 'banana' (which one? unpredictable - unordered!)
print(thisset) # => {'cherry', 'apple'}
# 4. clear() - empty the set
thisset.clear()
print(thisset) # => set()
# del deletes the whole variable, not an item:
thisset = {"apple", "banana"}
del thisset
print(thisset) # NameError: name 'thisset' is not defined
Which removal method should you use?
The four methods exist because “remove this thing” means different things in different situations, and picking the wrong one either hides bugs or creates them.
| Situation | Use | Why |
|---|---|---|
| The item should be there; if it isn’t, that’s a bug | remove() | The KeyError tells you your assumption broke, instead of failing silently later |
| The item might be there; either way is fine | discard() | No error, no if x in s guard needed — it just ensures the item is gone |
| You want to drain the set item by item | pop() | Returns the item so you can process it, and shrinks the set |
| You want to empty it but keep the variable | clear() | Reuses the same set object, so other references to it see the change |
The important nuance with pop(): because sets are unordered, you cannot
predict which item you get. If order matters at all, a set is the wrong data
structure — use a list and pop(0) or pop().
And note del thisset doesn’t remove an item — it removes the variable
name from your program entirely, which is why the subsequent print raises
NameError rather than showing an empty set.
Set operations — the real superpower
This is where sets shine: comparing two collections mathematically. Each operation has both a method and an operator form.
a.union(b)) and an operator form (a | b).a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
# UNION - everything in either set
a.union(b) # => {1, 2, 3, 4, 5, 6}
a | b # => {1, 2, 3, 4, 5, 6} (same thing)
# INTERSECTION - only what's in BOTH
a.intersection(b) # => {3, 4}
a & b # => {3, 4}
# DIFFERENCE - in a, but NOT in b
a.difference(b) # => {1, 2}
a - b # => {1, 2}
b - a # => {5, 6} (order matters!)
# SYMMETRIC DIFFERENCE - in one or the other, but NOT both
a.symmetric_difference(b) # => {1, 2, 5, 6}
a ^ b # => {1, 2, 5, 6}
What each operation is actually for
The operators aren’t just maths trivia — each answers a specific everyday question about two collections:
| Operation | The question it answers | Real example |
|---|---|---|
Union a | b | ”Everything from both, no repeats” | All tags used across two articles |
Intersection a & b | ”What do these two have in common?” | Users who bought both products |
Difference a - b | ”What’s in the first that’s missing from the second?” | Required fields the user hasn’t filled in yet |
Symmetric difference a ^ b | ”What’s changed / what don’t they share?” | Which permissions differ between two roles |
Notice that difference is the only one where order matters: a - b and
b - a answer opposite questions. The other three are symmetric — swapping the
operands gives the same result.
A concrete example — comparing yesterday’s users to today’s:
yesterday = {"ana", "ben", "cara"}
today = {"ben", "cara", "dev"}
today - yesterday # => {'dev'} new users
yesterday - today # => {'ana'} users who left
today & yesterday # => {'ben', 'cara'} returning users
today | yesterday # => everyone seen across both days
today ^ yesterday # => {'ana', 'dev'} users who came or went
That’s five useful business metrics in five one-liners — the same logic with lists would need nested loops.
In-place versions (modify the original)
a = {1, 2, 3}
a.update({3, 4}) # union in place -> {1, 2, 3, 4}
a.intersection_update({2, 3, 4, 9}) # keep only common -> {2, 3, 4}
a.difference_update({4}) # remove those -> {2, 3}
a.symmetric_difference_update({3, 7}) # keep non-shared -> {2, 7}
Comparing sets
a = {1, 2}
b = {1, 2, 3, 4}
a.issubset(b) # => True (every item of a is in b)
a <= b # => True
b.issuperset(a) # => True (b contains all of a)
b >= a # => True
a.isdisjoint({9}) # => True (no items in common)
Set comprehensions
Just like list comprehensions, but with {} — and duplicates vanish
automatically.
squares = {x**2 for x in range(6)} # => {0, 1, 4, 9, 16, 25}
evens = {x for x in range(10) if x % 2 == 0} # => {0, 2, 4, 6, 8}
lengths = {len(w) for w in ["hi", "yo", "hello"]} # => {2, 5} (dupe 2 dropped)
The #1 real-world use: removing duplicates
nums = [1, 2, 2, 3, 3, 3, 4]
unique = list(set(nums)) # => [1, 2, 3, 4] (order NOT preserved)
# If you need the original order preserved, use dict.fromkeys instead:
unique_ordered = list(dict.fromkeys(nums)) # => [1, 2, 3, 4] (order kept)
Why sets are fast
Checking x in collection is O(n) for a list (it scans every item) but
O(1) for a set (it jumps straight there via a hash). With big collections
the difference is enormous.
big_list = list(range(1_000_000))
big_set = set(big_list)
999_999 in big_list # slow - scans ~1,000,000 items
999_999 in big_set # instant - one hash lookup
Why the difference is so large
A list has no idea where any particular value lives, so x in my_list walks
from the first item forward, comparing as it goes. On average it checks half the
list, and if the item is absent it checks all of it. Double the list, double
the work — that’s what O(n) means.
A set skips the search entirely: it hashes x, jumps straight to that slot, and
looks. The collection’s size doesn’t change how long that takes — that’s
O(1), constant time.
The practical impact compounds when the check is inside a loop. Checking 10,000 items against a 10,000-item list is ~100 million comparisons; against a set it’s 10,000 instant lookups — the difference between “noticeably slow” and “imperceptible”.
# Slow: for each of 10,000 names, scan a 10,000-item list
blocked_list = [...]
flagged = [u for u in users if u in blocked_list] # O(n x m)
# Fast: convert once, then every check is instant
blocked = set(blocked_list)
flagged = [u for u in users if u in blocked] # O(n)
Rule of thumb: if you find yourself repeatedly asking “is this thing in that collection?”, convert it to a set first. The one-time cost of building the set pays for itself almost immediately.
Items must be hashable
Set items must be immutable (hashable) — the same rule as dict keys. Numbers, strings, and tuples work; lists and dicts don’t.
ok = {1, "two", (3, 4), True} # all hashable
bad = {[1, 2]} # TypeError: unhashable type: 'list'
frozenset — the immutable set
A frozenset is a set that can’t be changed after creation. This sounds
useless at first — why want a worse set? The answer is the hashability rule
from earlier: a normal set is mutable, so it can’t be hashed, so it can’t be a
dict key or live inside another set. Freezing it removes that restriction.
Think of the relationship as exactly parallel to list vs tuple: same data, one mutable and one not, and the immutable version is the one you can use as a key.
fs = frozenset([1, 2, 3])
fs | {4} # => frozenset({1, 2, 3, 4}) (operations still work)
fs.add(4) # AttributeError: no add method
# A frozenset CAN live inside a set (a normal set cannot):
groups = {frozenset({1, 2}), frozenset({3, 4})} # works
Set methods at a glance
| Method | Does |
|---|---|
add(x) | add one item |
update(it) | add all items from an iterable |
remove(x) | delete x — KeyError if missing |
discard(x) | delete x — no error if missing |
pop() | remove & return an arbitrary item |
clear() | remove everything |
copy() | shallow copy |
union(b) | all items from both |
intersection(b) | items in both |
difference(b) | items in the first only |
symmetric_difference(b) | items in exactly one |
issubset(b) / issuperset(b) | containment tests |
isdisjoint(b) | no shared items? |
Sets vs the other collections
| Set | List | Tuple | Dict | |
|---|---|---|---|---|
| Ordered | No | Yes | Yes | Insertion order |
| Duplicates | No | Yes | Yes | Unique keys |
| Mutable | Yes | Yes | No | Yes |
| Indexable | No | Yes | Yes | By key |
in speed | O(1) | O(n) | O(n) | O(1) |
Gotchas recap
{}is an empty dict, not a set. Useset().- No indexing —
s[0]raisesTypeError. - Order isn’t guaranteed — never rely on print order, and
pop()is effectively random. remove()raises on a missing item;discard()doesn’t.list(set(xs))loses the original order — usedict.fromkeys(xs)if order matters.- Items must be hashable — no lists inside sets.
Takeaways
- A set is an unordered collection of unique, hashable items.
- Its two killer features: automatic deduplication and O(1) membership tests.
- Master the four operations — union, intersection, difference, symmetric difference — and comparing two collections becomes a one-liner.
- Use
frozensetwhen you need an immutable (and therefore hashable) set.