The Story That Explains Sets
Now imagine you had a printed 500-name list on paper. You'd scan top to bottom until you found her — slow and painful. Now imagine that list is a set. You look at Alice's name, and instantly — without scanning — you know if she's in. That's not magic. That's a hash lookup, and it's the superpower of the Python
set.A set is a bag of unique, unordered things designed for one thing: answering "is this in there?" at blinding speed.
A Python set is an unordered collection of unique, hashable elements. It borrows from mathematics — union, intersection, difference — and pairs those operations with near-constant-time membership tests. Every duplicate you throw at it is silently discarded. Every membership question is answered in one hop.
A set is a list without duplicates that has traded ordering for speed.
You lose indexing (s[0] doesn't exist). You gain
O(1) membership tests and mathematical operations that would
take dozens of lines with lists. It's the perfect data structure for the
questions "is X in the group?" and "what do these groups have
in common?"
The Three Laws of Every Set
Before touching syntax, internalise the three properties that define every set. Every method, every operation, every edge case flows from these rules.
{1, 1, 2, 2, 3} immediately becomes {1, 2, 3}.
This is why sets are the one-line answer to deduplication.
s[0] throws
TypeError. Print output order isn't guaranteed. If order matters,
you want a list — not a set.
TypeError: unhashable type.
Creating a Set — Four Ways
# 1) Literal — the cleanest and most common
colours = {"red", "green", "blue"}
# 2) From an iterable using the set() constructor
letters = set("hello") # {'h', 'e', 'l', 'o'} — 'l' deduped
nums = set([1, 2, 2, 3, 3]) # {1, 2, 3}
# 3) Set comprehension — filter + transform in one step
squares = {n ** 2 for n in range(6)}
# 4) Empty set — the ONE case where the {} literal fails
empty = set() # CORRECT — empty set
oops = {} # WRONG — this is an empty DICT, not a set!
print(colours)
print(letters)
print(type(oops).__name__) # dict, not set
In Python, {} is always an empty dict — never a set.
To create an empty set, you must use set(). This is because
dict came first, so it got the curly braces first. Silent bugs live here — spot
them early.
Adding & Removing — The Mutation Methods
KeyError if it's not there.
KeyError if empty.
fruits = {"apple", "banana"}
fruits.add("cherry") # {'apple', 'banana', 'cherry'}
fruits.add("apple") # no change — silent duplicate
fruits.update(["kiwi", "mango"]) # adds both from a list
fruits.update("AB") # adds 'A' AND 'B' — strings are iterable!
fruits.discard("kiwi") # removes safely, silent if absent
fruits.discard("grape") # NOT there — but no error
try:
fruits.remove("grape") # NOT there — raises KeyError
except KeyError as e:
print(f"KeyError: {e}")
random_fruit = fruits.pop() # removes one arbitrary element
print(f"Popped: {random_fruit}")
fruits.clear() # now empty
print(f"After clear: {fruits}")
Use remove when the element should be there and its absence
is a bug — you want the crash. Use discard when absence is fine —
"delete if present, otherwise do nothing." Choosing the right one is choosing
between defensive and forgiving code.
Membership Testing — The Superpower
This is why sets exist. Checking whether an element is inside a collection is the single most common operation in data work, and sets do it in constant time — regardless of size.
allowed_users = {"alice", "bob", "charlie", "diana"}
# The 'in' operator — O(1) on average, regardless of set size
print("alice" in allowed_users) # True
print("eve" not in allowed_users) # True
# Same test on a 1-million-element set — still O(1)
big_set = set(range(1_000_000))
print(999_999 in big_set) # True — instant
| Size | Approx. time |
|---|---|
| 1,000 | ~10 microseconds |
| 100,000 | ~1 millisecond |
| 10,000,000 | ~100 milliseconds |
| Grows linearly — painful at scale | |
| Size | Approx. time |
|---|---|
| 1,000 | ~100 nanoseconds |
| 100,000 | ~100 nanoseconds |
| 10,000,000 | ~100 nanoseconds |
| Flat — same speed at every size | |
Set Operations — Visual Reference
This is where sets show off. Four operations you'd otherwise write nested loops for become a single symbol. Below is the complete visual reference — each Venn diagram shows the highlighted region as the result.
Union — A | B — Everything from Both
The highlighted region is the union — every element from either set, duplicates collapsed.
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
# Operator form — clean and Pythonic
print(a | b) # {1, 2, 3, 4, 5, 6}
# Method form — accepts ANY iterable, not just sets
print(a.union(b, [7, 8])) # {1, 2, 3, 4, 5, 6, 7, 8}
# In-place version — modifies a
a |= b
print(a) # {1, 2, 3, 4, 5, 6}
Intersection — A & B — Common Elements Only
Only the overlap is highlighted. This is the mathematical intersection.
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
# Operator form
print(a & b) # {3, 4}
# Method form
print(a.intersection(b)) # {3, 4}
# Real use: users who are BOTH admins AND active
admins = {"alice", "bob", "charlie"}
active = {"bob", "charlie", "diana", "eve"}
active_admins = admins & active
print(f"Active admins: {active_admins}")
Difference — A - B — Everything in A but Not in B
The crescent on the left is highlighted — elements of A that aren't shared with B.
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
print(a - b) # {1, 2} — in a, not in b
print(b - a) # {5, 6} — in b, not in a
print(a.difference(b)) # {1, 2}
# Real use: which requested features are NOT yet built?
requested = {"dark_mode", "exports", "api_v2", "webhooks"}
built = {"dark_mode", "exports"}
pending = requested - built
print(f"Still to build: {pending}")
Unlike union and intersection, a - b and b - a give
different results. This is the one set operation where order matters. Read it
as "start with the left set, then subtract the right set."
Symmetric Difference — A ^ B — In One But Not Both
Both crescents are highlighted — the middle overlap is excluded. This is (A ∪ B) − (A ∩ B).
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
print(a ^ b) # {1, 2, 5, 6}
print(a.symmetric_difference(b)) # {1, 2, 5, 6}
# Real use: what changed between two snapshots?
yesterday = {"file_a.csv", "file_b.csv", "file_c.csv"}
today = {"file_b.csv", "file_c.csv", "file_d.csv"}
changes = yesterday ^ today
print(f"Changed files: {changes}") # deleted + new
Set Relationships — Subset, Superset, Disjoint
Beyond the four operations, sets can also be compared as wholes. Is one set entirely contained in another? Do two sets share nothing at all? These relationships have one-shot method calls too.
Left: every element of B is also in A. Middle: they share some elements. Right: no shared elements at all.
a = {1, 2, 3, 4, 5}
b = {2, 3}
c = {7, 8, 9}
# Subset — is every element of b also in a?
print(b.issubset(a)) # True
print(b <= a) # True (operator form)
print(b < a) # True (STRICT subset — b != a)
# Superset — the mirror question
print(a.issuperset(b)) # True
print(a >= b) # True
# Disjoint — do they share ZERO elements?
print(a.isdisjoint(c)) # True — nothing in common
print(a.isdisjoint(b)) # False — they overlap
a <= b means "a is a subset of b, and possibly equal."
a < b means "a is a strict subset — everything in a is in b,
and b has at least one thing extra." A set is always a non-strict subset of
itself, but never a strict one.
Frozenset — The Immutable Cousin
A regular set is mutable — you can add and remove things. Sometimes you need a
hashable, unchangeable version: an element of another set, a
dictionary key, or a value guaranteed not to change. That's frozenset.
# Create a frozenset from any iterable
permissions = frozenset(["read", "write", "delete"])
# All non-mutating operations still work
print("read" in permissions) # True
print(permissions | {"admin"}) # returns NEW frozenset
# But mutation is forbidden
try:
permissions.add("admin")
except AttributeError as e:
print(f"Blocked: {e}")
# Because it's hashable, a frozenset can be:
# 1) an element of another set
role_bundles = {
frozenset({"read"}),
frozenset({"read", "write"}),
frozenset({"read", "write", "admin"})
}
print(len(role_bundles)) # 3
# 2) a dictionary key
cache = {
frozenset(["a", "b"]): "result_ab",
frozenset(["c", "d"]): "result_cd",
}
print(cache[frozenset(["b", "a"])]) # order doesn't matter
Practical Real-World Examples
Example 1 — Instant Deduplication of a List
emails = ["alice@ex.com", "bob@ex.com", "alice@ex.com",
"eve@ex.com", "bob@ex.com"]
# One line — order NOT preserved
unique_emails = list(set(emails))
print(unique_emails)
# If order matters, preserve first occurrence via dict.fromkeys()
unique_ordered = list(dict.fromkeys(emails))
print(unique_ordered)
Example 2 — Permission Checking
required_perms = {"read", "write"}
user_perms = {"read", "write", "delete", "share"}
# Does the user have EVERY required permission?
if required_perms <= user_perms:
print("Access granted")
else:
missing = required_perms - user_perms
print(f"Missing: {missing}")
Example 3 — Tag-Based Recommendations
user_tags = {"python", "data-science", "ml", "stats"}
article_tags = {"python", "ml", "deep-learning"}
# Match score = size of intersection
overlap = user_tags & article_tags
score = len(overlap) / len(user_tags | article_tags)
print(f"Common interests: {overlap}")
print(f"Jaccard similarity: {score:.2%}")
Example 4 — Finding What Changed
old_snapshot = {"config.yaml", "main.py", "utils.py", "data.csv"}
new_snapshot = {"config.yaml", "main.py", "helpers.py", "data.csv"}
added = new_snapshot - old_snapshot
removed = old_snapshot - new_snapshot
unchanged = old_snapshot & new_snapshot
print(f"Added: {added}")
print(f"Removed: {removed}")
print(f"Unchanged: {unchanged}")
Example 5 — Fast Filtering Against a Blocklist
# A million user IDs coming through
incoming_ids = range(1_000_000)
# 50,000 banned IDs — as a SET for O(1) lookup
banned = set(range(0, 1_000_000, 20))
# Filter — near-instant thanks to set lookup
allowed = [uid for uid in incoming_ids if uid not in banned]
print(f"Allowed: {len(allowed):,}")
print(f"Blocked: {len(banned):,}")
That last example does 1 million not in checks
against a 50,000-element blocklist. With a list, that's
50 billion comparisons — minutes. With a set, it's ~1 million hashes —
well under a second. Same code, one keyword changed, 60,000× speedup.
Performance — List vs Set for Membership
import timeit
# Same 100,000 items — stored as list vs set
data_list = list(range(100_000))
data_set = set(data_list)
# Worst case: search for last item
target = 99_999
t_list = timeit.timeit(
lambda: target in data_list, number=10_000
)
t_set = timeit.timeit(
lambda: target in data_set, number=10_000
)
print(f"List lookup: {t_list:.4f}s")
print(f"Set lookup: {t_set:.4f}s")
print(f"Set is {t_list / t_set:.0f}x faster")
| Operation | List | Set | Winner |
|---|---|---|---|
| Access by index | O(1) | Not supported | List |
Membership (in) | O(n) | O(1) | Set |
| Insert | O(1) append | O(1) add | Tie |
| Delete by value | O(n) | O(1) discard | Set |
| Deduplication | O(n²) naive | O(n) built-in | Set |
| Preserves order | Yes | No | List |
| Allows duplicates | Yes | No | Depends |
| Memory overhead | Low | ~3× higher (hash table) | List |
When NOT to Use a Set
collections.Counter, not a set.
TypeError: unhashable type. Convert to tuples or
frozensets first, or use a different structure.
# These all fail with TypeError: unhashable type
try:
bad = {[1, 2], [3, 4]} # lists inside a set
except TypeError as e:
print(f"Nope: {e}")
# FIX — convert inner lists to tuples (immutable, hashable)
good = {(1, 2), (3, 4)}
print(good) # works!
Complete Method Reference
| Method / Operator | What It Does | Mutates? |
|---|---|---|
s.add(x) | Add single element | Yes |
s.update(iter) / s |= t | Add many elements | Yes |
s.remove(x) | Remove x, KeyError if absent | Yes |
s.discard(x) | Remove x, silent if absent | Yes |
s.pop() | Remove and return arbitrary element | Yes |
s.clear() | Empty the set | Yes |
s | t / s.union(t) | Union — all elements | No |
s & t / s.intersection(t) | Intersection — common only | No |
s - t / s.difference(t) | Elements in s but not t | No |
s ^ t / s.symmetric_difference(t) | In s or t but not both | No |
s.intersection_update(t) | Keep only common elements | Yes |
s.difference_update(t) | Remove elements found in t | Yes |
s.symmetric_difference_update(t) | Toggle: XOR into s | Yes |
s <= t / s.issubset(t) | Is s a subset of t? | No |
s >= t / s.issuperset(t) | Is s a superset of t? | No |
s < t | Strict subset (s ⊂ t, s ≠ t) | No |
s.isdisjoint(t) | Do s and t share nothing? | No |
s.copy() | Shallow copy | No |
len(s) | Number of elements | No |
x in s | Membership test — O(1) | No |
Golden Rules
set(), never {}.
Empty curly braces create a dict. This bug is silent — no error — until you
try to .add() and get an AttributeError. Burn this
into muscle memory.
a & b, not a double for loop. Finding "items
in one but not the other" is a - b. Shorter, faster, and clearer.
sorted(my_set).
discard for forgiving code, remove for
strict code. Silent failures hide bugs — but so do unnecessary crashes.
Choose deliberately based on whether the element's absence would signal a
real problem.
frozenset when you need a set inside another
set — or as a dict key. Only immutable, hashable things can be keys
or elements. That's the entire purpose of the frozen variant.
collections.Counter,
not a set. Sets discard the exact information — how many —
that frequency problems need. Using a set here means going back to the
original data twice.