learn.aathan.in

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

PropertyWhat it means
UnorderedNo index positions — s[0] is an error. Print order may differ from insertion order.
UniqueDuplicates are silently dropped.
MutableYou 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.

SituationUseWhy
The item should be there; if it isn’t, that’s a bugremove()The KeyError tells you your assumption broke, instead of failing silently later
The item might be there; either way is finediscard()No error, no if x in s guard needed — it just ensures the item is gone
You want to drain the set item by itempop()Returns the item so you can process it, and shrinks the set
You want to empty it but keep the variableclear()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 B both A | B union - everything A & B intersection - in both A - B difference - in A only A ^ B symmetric - in one, not both
The four set operations. Each has a method 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:

OperationThe question it answersReal 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

MethodDoes
add(x)add one item
update(it)add all items from an iterable
remove(x)delete xKeyError 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

SetListTupleDict
OrderedNoYesYesInsertion order
DuplicatesNoYesYesUnique keys
MutableYesYesNoYes
IndexableNoYesYesBy key
in speedO(1)O(n)O(n)O(1)

Gotchas recap

  • {} is an empty dict, not a set. Use set().
  • No indexings[0] raises TypeError.
  • 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 — use dict.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 frozenset when you need an immutable (and therefore hashable) set.