7.2. Sets#
7.2.1. What is a set#
A set is an unordered collection of unique values. Unlike lists or tuples, sets do not allow duplicates, and the elements have no guaranteed order.
Here below is an example of a set. Note that it uses curly braces just like dictionaries.
s = {1, 2, 3, 3, 2}
print(s) ### {1, 2, 3}
{1, 2, 3}
Sets are useful when you care about membership: whether something is in a collection; rather than order or position.
The most common use cases are:
Removing duplicates from a list
Checking membership quickly
Comparing two collections (what’s shared, what’s different)
For sets, the four key properties that make sets different from other data structures:
Unordered: Elements have no guaranteed position. There is no first or last element.
Unique: Duplicates are automatically removed.
Mutable: You can add and remove elements.
Hashable elements only: Every element must be hashable. In practice this means immutable types:
int,float,str,tuple.
### 1. Sets are unordered, so the order of elements is not guaranteed.
{3, 1, 2} == {1, 2, 3} # True
True
### 2. Sets do not allow duplicate elements, so duplicates are automatically removed.
{1, 2, 2, 3, 3} # {1, 2, 3}
{1, 2, 3}
### 3. Sets are mutable, so you can add or remove elements after the set is created.
s = {1, 2, 3}
s.add(4) # works
s.remove(2) # works
s
{1, 3, 4}
%%expect TypeError
### 4. Sets can contain only hashable (immutable) elements, so you cannot include lists or other sets as elements.
{1, "hello", (1, 2)} # valid
{[1, 2], [3, 4]} # TypeError
TypeError: unhashable type: 'list'
7.2.1.1. Choosing the Right Structure#
Need |
Use |
|---|---|
Ordered collection, duplicates allowed |
|
Fixed data, multiple return values |
|
Key-value lookup |
|
Unique items, membership testing |
|
Hashable set (dict key, nested set) |
|
7.2.1.2. Hashable vs mutable#
We have seen that some data types are mutable and some are hashable. It’s now time to learn a little more about the.
Hashable means an object has a hash value that stays stable during its lifetime.
Mutable objects usually are not hashable (for example:
list,dict,set).Immutable objects are often hashable (for example:
int,float,str,tuple), but for tuples, all nested elements must also be hashable.For hash-table types:
dictrequires hashable keys, andsetrequires hashable elements.
7.2.1.2.1. Mutability#
For the property of mutability, let’s have a look over the general data structures. For in-place change methods for the common data structures, here is a comparison table. Tuple and strings are immutable, so there’s no methods available for the purpose.
Method |
List |
Dict |
Set |
Tuple |
String |
|---|---|---|---|---|---|
|
✓ |
||||
|
✓ |
||||
|
✓ |
||||
|
✓ |
✓ |
|||
|
✓ |
✓ |
|||
|
✓ |
✓ |
✓ |
||
|
✓ |
||||
|
✓ |
||||
|
✓ |
||||
|
✓ |
||||
|
✓ |
||||
|
✓ |
||||
|
✓ |
✓ |
✓ |
||
|
✓ |
||||
|
✓ |
||||
|
✓ |
7.2.1.3. In-place set operations#
In Python, mutable methods that modify in-place typically return None. The general rule in Python is:
Mutates in-place → returns
NoneReturns a new object → original unchanged
These methods modify the original set instead of creating a new one:
x = {1, 2, 3}
y = {3, 4, 5}
x.update(y) # union in-place
print("update:", x)
x = {1, 2, 3}
x.intersection_update(y)
print("intersection_update:", x)
x = {1, 2, 3}
x.difference_update(y)
print("difference_update:", x)
x = {1, 2, 3}
x.symmetric_difference_update(y)
print("symmetric_difference_update:", x)
update: {1, 2, 3, 4, 5}
intersection_update: {3}
difference_update: {1, 2}
symmetric_difference_update: {1, 2, 4, 5}
7.2.1.3.1. Hashability#
Let us compare set, list, and dict and this time focus on hashability.
Feature |
Set |
List |
Dict |
|---|---|---|---|
Ordered |
No |
Yes |
Yes (insertion order) |
Duplicates |
No |
Yes |
Keys: No, Values: Yes |
Mutable |
Yes |
Yes |
Yes |
Allows only hashable elements |
Yes |
No |
Keys only |
Lookup speed |
Avg O(1) |
O(n) |
Avg O(1) |
Use case |
Unique items, membership |
Ordered items |
Key-value pairs |
In computer science, hashable means that an object can be converted into a fixed-size integer (called a hash value or hash code) via a hash function. This hash value is used to quickly locate the object in data structures like hash tables, dictionaries, and sets. For something to be hashable, it generally needs to satisfy two properties:
Consistency: the hash value must not change during the object’s lifetime. This is why mutable objects (like Python lists) are typically not hashable because if their contents change, their hash would need to change too, which breaks lookups.
Equality consistency: if two objects are considered equal (
a == b), they must have the same hash value. The reverse isn’t required; two different objects can share a hash (called a collision), but equal objects must hash identically.
Hash-based data structures like hash maps and hash sets rely on hash values to place and find items in O(1) average time. If you want to use an object as a dictionary key or store it in a set, it needs to be hashable.
In summary, hashable means you can be consistently fingerprinted with a number, which is the prerequisite for being usable as a key in fast lookup structures.
key → hash function → index → bucket in array
"cat" → hash("cat") → 42 → array[42]
print(hash("cat")) ### -9223372036854775808
print(hash("dog")) ### -867955804616931492
print(hash("cat")) ### -9223372036854775808
762818412824561485
1239000105622330567
762818412824561485
7.2.2. Creating sets#
A set is created using curly braces {} with comma-separated values, or by using the set() constructor.
Unlike lists, sets are unordered and do not allow duplicates - if you add the same value twice, it only appears once. This makes sets useful for storing unique items. One important gotcha: an empty {} creates a dict, not a set - you must use set() for an empty set.
s = {1, 2, 3, 3} # duplicate 3 is ignored
print(s) # {1, 2, 3}
s = set([1, 2, 2, 3]) # from a list
print(s) # {1, 2, 3}
s = set() # empty set
d = {} # this is a dict, not a set!
{1, 2, 3}
{1, 2, 3}
set elements must be hashable. Since sets use hashing internally to enforce uniqueness and enable fast lookup, all elements must be hashable. This means you can store integers, floats, strings, and tuples in a set, but not lists or dicts.
%%expect TypeError
# Valid
s = {1, "hello", (1, 2)}
# Invalid
s = {[1, 2], [3, 4]} # TypeError: unhashable type: 'list'
TypeError: unhashable type: 'list'
Similarly, a tuple is hashable only if all of its elements are hashable.
print(hash((1, 2))) # ok
print(hash((1, (2, 3)))) # ok
try:
hash((1, [2, 3])) # list inside tuple => unhashable
except TypeError as e:
print(e)
-3550055125485641917
7267574591690527098
unhashable type: 'list'
7.2.2.1. Accessing Elements#
Sets have no indexing or positional access. Because sets are unordered, there’s no concept of “first” or “second” element, so index access makes no sense.
Compared with list and dictionary, dict is the only one with a built-in safe getter. A list requires a guard clause.
List |
Dict |
Set |
|
|---|---|---|---|
Access |
by index |
by key |
no direct access |
Safe get |
|
|
|
Built-in safe method |
none |
|
none |
%%expect IndexError
d = {'x': 1, 'y': 2}
d.get('z', 'not found') # 'not found' — no error
l = [10, 20, 30]
l[5] # IndexError! ### uncomment to see the error
IndexError: list index out of range
For list, a safe way to handle a possible out-of-range error is to use a guard clause.
l = [10, 20, 30]
result = l[5] if 5 < len(l) else None # None — safe
print(result) # None
None
To work with elements in a set, you can:
Method |
Example |
Notes |
|---|---|---|
Membership check |
|
True/False only, no error |
Iterate |
|
no guaranteed order |
Convert to list |
|
order not guaranteed |
|
|
returns an arbitrary element |
### check membership
if "apple" in s:
print("found")
### iterate over a set
for element in s:
print(element)
1
(1, 2)
hello
### conversion to list
l = list(s)
print(l)
[1, (1, 2), 'hello']
### s.pop() removes and returns an arbitrary element from the set. If the set is empty, it raises a KeyError.
s = {1, 2, 3}
s.pop()
1
7.2.3. Set Operations#
Sets support mathematical operations that make it easy to compare two collections. Each operation has both an operator shorthand and a method form which produce the same result:
Operation |
Operator |
Method |
Description |
|---|---|---|---|
Union |
|
|
All elements from both sets |
Intersection |
|
|
Only elements common to both sets |
Difference |
|
|
Elements in |
Symmetric Difference |
|
|
Elements in either set, but not in both |
a = {1, 2, 3}
b = {3, 4, 5}
Note that, operator forms (|, &, -, ^) require set operands. Method forms are more flexible and can take any iterable.
print(a.union([3, 4, 5])) # works: method accepts iterable
try:
a | [3, 4, 5] # TypeError: right side is a list
except TypeError as e:
print(e)
{1, 2, 3, 4, 5}
unsupported operand type(s) for |: 'set' and 'list'
7.2.3.1. Union#
All elements from both sets
a | b # {1, 2, 3, 4, 5}
a.union(b) # same
{1, 2, 3, 4, 5}
7.2.3.2. Intersection#
Only elements in both sets.
a & b # {3}
a.intersection(b) # same
{3}
7.2.3.3. Difference#
Elements in a but not in b
a - b # {1, 2}
a.difference(b) # same
{1, 2}
7.2.3.4. Symmetric Difference#
Elements in either set, but not both
a ^ b # {1, 2, 4, 5}
a.symmetric_difference(b) # same
{1, 2, 4, 5}
### Exercise: Basic Set Operations
# 1. Create A = {1, 2, 3, 4} and B = {3, 4, 5, 6}.
# 2. Print A | B, A & B, A - B, and A ^ B.
### Your code starts here.
### Your code ends here.
Union: {1, 2, 3, 4, 5, 6}
Intersection: {3, 4}
Difference A-B: {1, 2}
Symmetric Difference: {1, 2, 5, 6}
7.2.4. Set Methods#
Python sets have methods for adding, removing, and checking elements.
Method |
Description |
|---|---|
|
Adds a single element |
|
Adds multiple elements |
|
Returns a shallow copy of the set |
|
Removes element, raises |
|
Removes element, silent if missing |
|
Removes and returns an arbitrary element |
|
Empties the set |
|
Returns |
|
Returns |
|
Returns |
7.2.4.1. Adding elements:#
add()update(): adds multiple elements, similar todict.update().
s = {1, 2, 3}
s.add(4) # {1, 2, 3, 4}
s.update([5, 6]) # {1, 2, 3, 4, 5, 6}
s
{1, 2, 3, 4, 5, 6}
7.2.4.2. Copy vs aliasing#
Sets are mutable, so assignment shares the same object. Use .copy() when you need an independent set.
a = {1, 2}
b = a # alias (same object)
c = a.copy() # separate object
a.add(3)
print("a:", a)
print("b:", b)
print("c:", c)
a: {1, 2, 3}
b: {1, 2, 3}
c: {1, 2}
7.2.4.3. Removing elements:#
remove()discard()pop()clear()
s = {1, 2, 3}
s.remove(3) # raises KeyError if not found
print(s) # {1, 2}
s.discard(3) # silent if not found — safer
print(s) # {1, 2}
s.pop() # removes and returns an arbitrary element
print(s) # which item is removed is arbitrary
s.clear() # empties the set
print(s) # set()
{1, 2}
{1, 2}
{2}
set()
If you use remove() in a for loop for a set, you may run into an error because Python creates an internal iterator that tracks position inside the set. The moment you call s.remove(), the set’s structure changes and the iterator breaks and raises an error.
The solution to this problem is to iterate over a copy or just use a while loop.
%%expect RuntimeError
s = {1, 2, 3,}
for item in s:
s.remove(item)
print(f"Processing {item}")
Processing 1
RuntimeError: Set changed size during iteration
### iterate through copy of the set while modifying the original
for item in s.copy(): # iterate over copy
s.remove(item) # modify original safely
print(f"Processing {item}")
s
Processing 2
Processing 3
set()
# process all items, consuming the set
s = {1, 2, 3}
while s:
item = s.pop()
print(f"Processing {item}")
# s is now empty
Processing 1
Processing 2
Processing 3
s.pop() is useful when you want to process and consume a set one element at a time, and you don’t care which element you get. This behavior is different from pop() in list and dict.
|
|
|
|
|---|---|---|---|
Removes |
by index |
by key |
arbitrary element |
Returns |
removed value |
removed value |
removed value |
Default arg |
index (default |
key + optional default |
none |
If not found |
|
|
|
Predictable? |
yes (by index) |
yes (by key) |
no (arbitrary item) |
7.2.4.4. Checking membership:#
s = {1, 2, 3}
print(3 in s) # True
print(3 not in s) # False
True
False
7.2.4.5. Subset & Superset:#
a = {1, 2}
b = {1, 2, 3}
a.issubset(b) # True — all of a is in b
b.issuperset(a) # True — b contains all of a
a.isdisjoint(b) # False — they share elements
False
Operator equivalents for subset/superset are often used in real code:
a = {1, 2}
b = {1, 2, 3}
print(a < b) # proper subset
print(a <= b) # subset
print(b > a) # proper superset
print(b >= a) # superset
print(a <= a) # True (same set is subset of itself)
print(a < a) # False (not a proper subset)
True
True
True
True
True
False
### Exercise: Update and Membership Check
# 1. Start with fruits = {"apple", "banana"}.
# 2. Add "cherry" and update with ["banana", "mango"].
# 3. Remove "apple" safely.
# 4. Print whether "mango" is in fruits.
# 5. Print whether {"banana", "cherry"} is a subset of fruits.
### Your code starts here.
### Your code ends here.
True
True
7.2.5. Frozensets#
A frozenset is an immutable version of a set. Once created, elements cannot be added or removed. This is useful when, for example, you need to protect data from modification, to use set as dict key or set element, or for graph edges and combinations.
%%expect AttributeError
fs = frozenset({1, 2, 3})
fs.add(4) # AttributeError — frozensets are immutable
AttributeError: 'frozenset' object has no attribute 'add'
7.2.5.1. Creating a frozenset#
The syntax for creating a frozenset is:
frozenset() # empty frozenset
frozenset(iterable) # from any iterable (list, set, string, etc.)
fs = frozenset([1, 2, 3])
print(fs) # frozenset({1, 2, 3})
fs = frozenset("hello")
print(fs) # element order may vary
frozenset({1, 2, 3})
frozenset({'l', 'e', 'o', 'h'})
7.2.5.2. Why use a frozenset?#
Because it is immutable, a frozenset is hashable, which means it can be:
used as a dictionary key, or
stored inside another set.
# As a dict key
d = {frozenset({1, 2}): "pair"}
# Inside a set
s = {frozenset({1, 2}), frozenset({3, 4})}
Feature |
set |
frozenset |
|---|---|---|
Mutable |
Yes |
No |
Hashable |
No |
Yes |
Can be dict key |
No |
Yes |
Set operations |
All |
Non-mutating only |
7.2.6. Set Performance#
When you store a key in a dictionary or an element in a set, Python calls hash() on it to compute an integer, then uses that integer to determine where to store the value in memory. This is called a hash table.
The benefit is that lookups are usually O(1) on average: Python jumps directly to a bucket via hash instead of scanning the whole collection. In the worst case (many collisions), lookup can degrade toward O(n).
# O(n) — scans the entire list
"apple" in ["apple", "banana", "cherry"]
# Average O(1) — jumps directly via hash bucket
"apple" in {"apple", "banana", "cherry"}
True
O(n) and O(1) come from Big O notation, which describes how the speed of an operation scales with the size of the data. The full treatment of Big O comes in the Algorithms chapter, where sorting and searching will be covered.
For now, just know that:
Average O(1): hash-table lookups in
dict/setare constant time on average, because Python uses the hash to jump to a bucket.Worst-case O(n): if many keys collide into the same bucket, Python may need to scan multiple entries.
O(n): linear time. The operation gets slower as the collection grows. A list search is O(n) because Python checks each element one by one until it finds a match:
d = {"apple": 1, "banana": 2, "cherry": 3}
d["apple"] # same speed whether dict has 3 or 3 million items
1
"apple" in ["apple", "banana", "cherry"] # checks up to n items
True
In short,
O(1) is like looking up a word in a dictionary: you go directly to the page
O(n) is like finding a word in a novel: you read until you find it
You already saw this in the dictionary section, and the same idea applies here:
O(1) means near-constant lookup time on average (
key in dict,item in set).O(n) means linear time, where Python may scan through many elements (
item in list).
So for membership checks, sets are usually much faster than lists as data grows.
7.2.6.1. Performance Demo#
This benchmark mirrors the dictionary example: same data size, worst-case target (n - 1), and timing for membership checks.
import time
n = 1_000_000
big_list = list(range(n))
big_set = set(range(n))
target = n - 1 # worst case for linear list search
# O(n): list search scans until it finds target
start = time.perf_counter() # time.perf_counter() is better for benchmarking code
result = target in big_list
list_time = time.perf_counter() - start
# Average O(1): set lookup jumps to hash bucket
start = time.perf_counter()
result = target in big_set
set_time = time.perf_counter() - start
print(f"List lookup (O(n)): {list_time:.6f} s")
print(f"Set lookup (avg O(1)): {set_time:.6f} s")
print(f"Set was ~{list_time / set_time:.0f}x faster")
List lookup (O(n)): 0.004879 s
Set lookup (avg O(1)): 0.000021 s
Set was ~231x faster
7.2.7. Set Comprehensions#
A set comprehension builds a set from an expression, using the same syntax as a list comprehension but with curly braces {}. Because the result is a set, duplicates are automatically discarded.
The syntax for creating sets is:
{expression for item in iterable}
{expression for item in iterable if condition}
# Create a set of squares
square_set = {x**2 for x in range(-3, 4)}
print("Square set:", square_set)
# Remove duplicates and transform
sentence = "the quick brown fox jumps over the lazy dog"
unique_lengths = {len(word) for word in sentence.split()}
print("Unique word lengths:", unique_lengths)
# Filter vowels
vowels = {char.lower() for char in "Hello World" if char.lower() in 'aeiou'}
print("Vowels found:", vowels)
Square set: {0, 9, 4, 1}
Unique word lengths: {3, 4, 5}
Vowels found: {'e', 'o'}
### Exercise: Set Comprehension Practice
# 1. From nums = [1, 2, 2, 3, 4, 4, 5], build a set of squared values.
# 2. From text = "apple banana apple cherry", build a set of unique word lengths.
# 3. Print both sets.
### Your code starts here.
### Your code ends here.
Squares: {1, 4, 9, 16, 25}
Unique lengths: {5, 6}
7.2.7.1. Comparing with dict Comprehension#
# Set comprehension — curly braces, no colon
{x**2 for x in range(5)}
# Dict comprehension — curly braces WITH colon
{x: x**2 for x in range(5)}
{0: 0, 1: 1, 2: 4, 3: 9, 4: 16}