Python Module 5 – Built-in data structures: lists, dicts, sets and more
Sets and frozensets for membership and maths
Remove duplicates and test membership fast with Python sets, use union, intersection and difference, see why set order changes, and use frozenset.
What you will learn
- Remove duplicates with set() and keep the first-seen order with dict.fromkeys()
- Apply union, intersection, difference and symmetric difference
- Explain why x in a set stays fast as the set grows, and when a list scan is just as good
- Explain why a set of strings can print in a different order on every run
- Use frozenset when a hashable set is needed
Before you start
What you’ll learn · Before you start
What you will learn
- Remove duplicates with set() and keep the first-seen order with dict.fromkeys()
- Apply union, intersection, difference and symmetric difference
- Explain why x in a set stays fast as the set grows, and when a list scan is just as good
- Explain why a set of strings can print in a different order on every run
- Use frozenset when a hashable set is needed
Before you start
On this page
Proving that a number is not in a list of 100,000 takes 100,000 comparisons, because a list can only be checked item
by item. A set, such as {"tea", "rice"}, is an unordered collection of distinct values that answers the same
question by looking in one place, so its membership test stays fast however big it grows. Adding a value that is
already there changes nothing, so turning a list into a set removes its duplicates, and sets do the arithmetic of
groups: union, intersection, difference and symmetric difference, written |, &, - and ^.
Below you can watch a list and a set search the same five seat numbers, sort the members of two clubs into regions,
see why a set of strings prints in another order on each run, and remove repeats from a list while keeping its order.
frozenset, the set that cannot change, closes the lesson.
A set keeps one copy of each value
A visitor log of six entries, [3, 1, 3, 2, 1, 3], holds only three different visitors, and set(visits) finds them
in one step, because a set keeps each value once. Braces with values make a set too, and repeats vanish as it is
built:
empty = set() # the only way to write an empty set ...
not_a_set = {} # ... because {} is an empty dictionary
print(type(empty).__name__, type(not_a_set).__name__)
tags = {"python", "lists", "python"} # the repeat disappears
tags.add("sets")
tags.add("lists") # already there: nothing changes
tags.discard("java") # discard() is quiet about a missing item
print(len(tags), "sets" in tags, sorted(tags))
visits = [3, 1, 3, 2, 1, 3]
print(sorted(set(visits)), len(set(visits)), "different visitors")
tags.remove("java") # remove() raises KeyError for a missing item Output (exit status 1)
set dict 3 True ['lists', 'python', 'sets'] [1, 2, 3] 3 different visitors
Printed as an error (standard error)
Traceback (most recent call last):
File "set_basics.py", line 14, in <module>
tags.remove("java") # remove() raises KeyError for a missing item
~~~~~~~~~~~^^^^^^^^
KeyError: 'java'
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 set_basics.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The empty set has to be written set(), because {} was taken long ago by the empty dictionary. add(x) adds one
value and does nothing when it is already there. discard(x) removes a value if it is present, while remove(x)
insists on it and raises KeyError, as the last line shows. len() counts the distinct values, so
len(set(visits)) answers “how many different visitors?” in one expression. Press Edit, change the last line to
tags.discard("java") and run it again: the program now ends without an error.
Sets have no positions, so there is no s[0] and no slicing. To print one in a predictable order, sort it, as the
examples in this lesson do, apart from the one that shows why: sorted(s) returns a sorted list. Like the keys of a
dictionary, the items of a set must be hashable: A value that can be a dict key or a set item, because its hash never changes while it exists: numbers, strings and tuples of hashable values are hashable; lists, dicts and sets are not. More in “functools: cache, partial, reduce and more”; the next sections show why.
{} for an empty set
seen = {} makes an empty dictionary, so a later seen.add(x) fails with AttributeError: a dictionary has no
add() method. Write seen = set() for a set that starts empty.
Checkpoint
Check: Making and changing a set
Union, intersection and difference pick regions of two sets
Two clubs that share some members show all four operations:
chess = {"Asha", "Ravi", "Mei", "Dev"}
drama = {"Mei", "Kiran", "Asha", "Zoya"}
print(sorted(chess & drama)) # in both clubs: intersection
print(sorted(chess | drama)) # in either club: union
print(sorted(chess - drama)) # in chess but not drama: difference
print(sorted(chess ^ drama)) # in exactly one club: symmetric difference
print({"Asha", "Mei"} <= chess, chess.isdisjoint({"Zoya", "Bilal"}))
print(sorted(chess.union(["Bilal", "Asha"]))) # methods take any iterable Output
['Asha', 'Mei'] ['Asha', 'Dev', 'Kiran', 'Mei', 'Ravi', 'Zoya'] ['Dev', 'Ravi'] ['Dev', 'Kiran', 'Ravi', 'Zoya'] True True ['Asha', 'Bilal', 'Dev', 'Mei', 'Ravi']
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 clubs.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Every student of either club falls in one of three regions: chess only, both clubs, or drama only. The diagram sorts the six students into those regions, and each operator keeps some of them:
The four set operators on two clubs
Text description of the diagram
The diagram shows the example clubs.py. The top row splits everyone in either club into three regions.
- Chess only: Dev and Ravi.
- Both clubs: Asha and Mei.
- Drama only: Kiran and Zoya.
The bottom row says which regions each operator keeps.
- chess - drama, the difference, keeps the chess-only region: Dev and Ravi.
- chess & drama, the intersection, keeps the middle region: Asha and Mei.
- chess ^ drama, the symmetric difference, keeps both outer regions: Dev, Ravi, Kiran and Zoya.
- chess | drama, the union, keeps all three regions: all six people.
a | b, the union, holds everything in either set.a & b, the intersection, holds what is in both.a - b, the difference, holds what is inabut not inb;b - ais a different set.a ^ b, the symmetric difference: The items that are in exactly one of two sets, a ^ b: everything in either set except what the two share. {1, 2} ^ {2, 3} is {1, 3}., holds what is in exactly one of them.
a <= b asks whether every item of a is also in b, which makes a a subset: A set whose every item is also in another set. a <= b tests it; a < b also needs b to hold something more, which makes a a proper subset. of b (<
also requires b to have something more), and a.isdisjoint(b) whether the two share nothing. The operators need
sets on both sides: chess | ["Bilal"] raises TypeError. The named methods, union(), intersection(),
difference(), symmetric_difference(), issubset() and the others, accept any iterable, which is why
chess.union(["Bilal", "Asha"]) works. Each operator also has an in-place form, such as a |= b, that changes a
instead of building a new set. Press Edit, add print(sorted(drama - chess)) and run it: Kiran and Zoya, the
drama-only region.
Checkpoint
Check: Union, intersection and difference
A set finds a value without looking through every item
Take five booked seat numbers, [12, 30, 7, 25, 41], and ask whether seat 19 is among them. A list has no shortcut:
19 in seats compares 19 with each item in turn, and only after the last one can it say no. Step through it:
- being compared or looked at
- visited or ruled out
Pseudo-code
-
for i from 0 to n − 1: -
if a[i] = target: return i -
return "not found"
Look for 19 by checking each value from the left until it turns up or the array ends.
When it finishes: 19 not found.
Try your own input
Five comparisons for five seats, and for a list of 100,000 it would be 100,000. A set stores its items in a
hash table: A table of slots in which each key is kept at a place worked out from its hash, so a lookup looks in one slot or a few instead of comparing every key. Python's dict and set are hash tables. More in “Dictionaries: keys, values and views”, as a dictionary stores its keys. To answer 19 in booked, for the set
booked = set(seats), Python computes the hash: The whole number hash() works out from a value. Values that are equal have equal hashes, and a dict or a set uses the hash to decide where to look, then compares the keys it finds there with ==. More in “Dictionaries: keys, values and views” of 19 and looks only where 19 would be
stored. The next figure is a small model
of such a table, the textbook kind with 8 slots: a whole number k goes to slot k mod 8, and when that slot is taken,
to the next one. Step through the five seats going in, then the two lookups:
- being compared or looked at
- being moved or written
- in its final place
- visited or ruled out
Pseudo-code
-
insert(k): for i from 0: s ← (k mod m + i) mod m -
if slot s is empty or a tombstone: put k there; stop -
find(k): probe the same sequence until k or an empty slot -
delete(k): find k, then leave a tombstone in its slot
A table of 8 slots with linear probing: on a collision, try the next slot, then the one after. The home slot of a key k is k mod 8.
When it finishes: 5 keys stored.
Try your own input
41 wanted slot 1, which 25 had taken, so it went into slot 2, and finding it took two looks. Finding that 19 is missing took one: slot 3 was empty, so 19 cannot be anywhere else. Python’s dictionaries and sets are resizable hash tables, and the time a test takes depends on how many values share the places it looks in, not on how many values the set holds. This program measures it on 100,000 numbers:
from timeit import timeit
n = 100_000
as_list = list(range(n))
as_set = set(as_list)
missing = -1 # not there: the list must look at every item to be sure
for name, items, runs in (("list", as_list, 200), ("set", as_set, 200_000)):
seconds = timeit("missing in items", globals={"missing": missing, "items": items}, number=runs)
print(f"{name}: {seconds / runs * 1e6:.3f} microseconds per lookup of a missing value") Output
list: 613.981 microseconds per lookup of a missing value set: 0.013 microseconds per lookup of a missing value
This output changes from run to run: the times depend on the computer and on what else it is doing
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 membership_timing.py
The value is missing on purpose, the worst case for the list, which had to look at all 100,000 numbers on every lookup. The set answered in a small fraction of a microsecond, tens of thousands of times faster. The comparison favours the set more the longer the list gets, but two things limit it. Building the set is itself a pass over the list, so for a single lookup the list scan is just as good; the set pays off when you look up many values. And for a handful of items both are so fast that the difference does not matter.
Key idea
A list answers in by comparing item after item, while a set goes by the hash to the one place the value would be,
so build a set when you will test many values against the same collection.
Checkpoint
Check: Fast membership tests
A set has no order you can rely on
The order in which a set prints comes from the hashes of its items, and the hashes of strings change from one run of
Python to the next. Python picks a random salt for string hashes when it starts, a protection against input crafted
to make every hash collide; this is called hash randomisation: The random value Python mixes into the hashes of str and bytes objects, chosen afresh when each process starts, so a set of strings can come out in another order on each run. PYTHONHASHSEED fixes it.. This program starts
a new Python three times, each time with a fixed salt chosen through the PYTHONHASHSEED environment variable, and
prints the same set:
import os
import subprocess
import sys
program = 'print({"mango", "apple", "kiwi", "fig", "plum"}); print({16, 1, 8})'
for seed in ("1", "2", "3"):
env = dict(os.environ, PYTHONHASHSEED=seed) # fix the hash seed of a new Python
run = subprocess.run([sys.executable, "-c", program], env=env, capture_output=True, text=True)
first, second = run.stdout.splitlines()
print(f"seed {seed}: {first} {second}")
print("sorted:", sorted({"mango", "apple", "kiwi", "fig", "plum"}), sorted({16, 1, 8})) Output
seed 1: {'mango', 'kiwi', 'plum', 'fig', 'apple'} {16, 1, 8}
seed 2: {'fig', 'plum', 'mango', 'apple', 'kiwi'} {16, 1, 8}
seed 3: {'mango', 'fig', 'kiwi', 'apple', 'plum'} {16, 1, 8}
sorted: ['apple', 'fig', 'kiwi', 'mango', 'plum'] [1, 8, 16]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 hash_order.py
The five fruits come out in a different order each time, while the set of numbers prints the same way in every run,
because the hash of a small integer is the integer itself. That order is still not sorted: {16, 1, 8} prints as it
was written, not as {1, 8, 16}. Two rules follow. Sort a set before you show it, and compare sets with ==, which
ignores order, never by comparing their printed text. Setting PYTHONHASHSEED to a fixed value is a debugging aid,
not a fix.
Checkpoint
Check: Why a set's order changes
A dictionary removes duplicates and keeps the first-seen order
set(items) removes duplicates but forgets the order of the items. When the first-seen order matters, use a
dictionary, which keeps insertion order, and take its keys. Step through dict.fromkeys() on a list of five emails
with two repeats:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Five emails, two repeats. dict.fromkeys(emails) reads the list from position 0 and makes each email a key of a new, empty dictionary.
- A new key. b@x.org is not a key yet, so it becomes the first key of the dictionary.
- Another new key. a@x.org is new as well, and goes in after it, as the second key.
- A repeat changes nothing. b@x.org is already a key. A dictionary holds each key once, so nothing is added, and the key keeps the place it got at position 0.
- The third key. c@x.org has not been seen before, so it becomes the third key.
- Another repeat. a@x.org is a key already, so again nothing changes.
- Back to a list. list() of the dictionary gives its keys in the order they were added: ['b@x.org', 'a@x.org', 'c@x.org'], each email once, in the order it first appeared. Five emails and three keys make the 2 repeats the program counts.
A repeat finds its email already a key and changes nothing, so every key keeps the place where its email first appeared. The program does the same in one line, next to the set version for comparison:
emails = ["b@x.org", "a@x.org", "b@x.org", "c@x.org", "a@x.org"]
print(list(dict.fromkeys(emails))) # first-seen order kept
print(sorted(set(emails))) # a set keeps no order, so sort it to show it
print(len(emails) - len(set(emails)), "repeats")
rows = [["a", 1], ["a", 1]]
print(set(rows)) # lists cannot be set elements Output (exit status 1)
['b@x.org', 'a@x.org', 'c@x.org'] ['a@x.org', 'b@x.org', 'c@x.org'] 2 repeats
Printed as an error (standard error)
Traceback (most recent call last):
File "dedupe_order.py", line 8, in <module>
print(set(rows)) # lists cannot be set elements
~~~^^^^^^
TypeError: cannot use 'list' as a set element (unhashable type: 'list')
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 dedupe_order.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
dict.fromkeys(emails) makes a dictionary whose keys are the emails, each once, in the order they first appeared,
and list() turns its keys back into a list. Both ways need hashable items. A list of lists cannot become a set, and
Python 3.14 says so plainly: TypeError: cannot use 'list' as a set element (unhashable type: 'list'). Turn each
inner list into a tuple first.
Checkpoint
Check: Removing duplicates in order
frozenset is a set that cannot change, so it can be a key
A set can change, so it is not hashable: it cannot be a dictionary key or an item of another set. frozenset is
the immutable version, with every operation that reads a set and none that changes one:
relations = {
frozenset({"Asha", "Ravi"}): "cousins",
frozenset({"Mei", "Dev"}): "neighbours",
}
print(relations[frozenset({"Ravi", "Asha"})]) # the order inside does not matter
seen = set()
seen.add(frozenset({"tea", "milk"}))
print(frozenset({"milk", "tea"}) in seen)
letters = frozenset("abc")
print(sorted(letters | {"d"}), type(letters | {"d"}).__name__)
seen.add({"sugar"}) # a plain set cannot go inside a set Output (exit status 1)
cousins True ['a', 'b', 'c', 'd'] frozenset
Printed as an error (standard error)
Traceback (most recent call last):
File "frozen.py", line 14, in <module>
seen.add({"sugar"}) # a plain set cannot go inside a set
~~~~~~~~^^^^^^^^^^^
TypeError: cannot use 'set' as a set element (unhashable type: 'set')
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 frozen.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
A frozenset makes a good key for something that has no order of its own, such as a pair of people: {"Asha", "Ravi"} and {"Ravi", "Asha"} are equal, so both find the same entry. A set of sets must be a set of frozensets,
and a plain set inside one fails. Operators between a frozenset and a set return the type of the left-hand operand,
so letters | {"d"} is a new frozenset.
Checkpoint
Check: frozenset as a key
Practise: hours that suit everyone, and spotting repeats
The exercise has two small set problems from a timetable: the hours that every person in a group has open, which is an intersection of their calendars, and a test for repeated items, which compares a list with the set made from it. Write each function and run the sample tests; the first hint names the operator to start with:
Exercise · Easy · Python
Find the hours everyone is free, and spot repeated items
Write two functions with sets.
common_free_slots(calendars)takes one list per person of the hours that person is free, such as[[9, 10, 11, 14], [10, 11, 15], [11, 10, 9]], and returns the hours free for everyone, as a sorted list:[10, 11]. A person may list an hour twice. With no calendars at all, or when one person has no free hours, the answer is[].has_duplicates(xs)returnsTruewhen some item of the listxsappears more than once andFalseotherwise; an empty list has none. It must work with a set, so a list holding unhashable items, such as[[1], [1]], raisesTypeError(the set does that for you).
Starter code · slots.py
def common_free_slots(calendars):
"""Hours free in every calendar, as a sorted list."""
return []
def has_duplicates(xs):
"""True when some item of xs appears more than once."""
return False The sample tests · test_slots.py
from slots import common_free_slots, has_duplicates
def test_common_slots():
"""finds the hours free in every calendar, sorted"""
assert common_free_slots([[9, 10, 11, 14], [10, 11, 15], [11, 10, 9]]) == [10, 11]
assert common_free_slots([[16, 9, 12], [12, 16]]) == [12, 16]
def test_one_person_and_repeats():
"""works for one calendar and for hours listed twice"""
assert common_free_slots([[14, 9, 14]]) == [9, 14]
assert common_free_slots([[10, 10], [10]]) == [10]
def test_no_common_slots():
"""returns an empty list when nobody, or not everybody, is free"""
assert common_free_slots([]) == []
assert common_free_slots([[9, 10], []]) == []
assert common_free_slots([[9], [10]]) == []
def test_duplicates():
"""spots repeated items"""
assert has_duplicates([1, 2, 1]) is True
assert has_duplicates(["tea", "rice", "tea"]) is True
assert has_duplicates([1, 2, 3]) is False
assert has_duplicates(["a", "A"]) is False
assert has_duplicates([]) is False
def test_unhashable_items():
"""raises TypeError for items a set cannot hold"""
try:
has_duplicates([[1], [1]])
except TypeError:
return
raise AssertionError("expected TypeError for a list of lists") A hint
Start with a set of the first person's hours and keep only the hours that are also in each next person's set: &, or &= to update in place. Sort the set at the end, because a set has no order. For has_duplicates, compare the length of the list with the length of a set made from it: repeats disappear in the set.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Six ideas, from making a set to frozensets, are in the self-check. It opens with whichever of them your answers so far have shown to be the shakiest, and each choice comes with the reason it is right or wrong:
Practice that adapts to you
Practise this lesson
17 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · Why is x in a_set usually faster than x in a_list, and when is it not?
A set is a hash table. For x in a_set, Python computes hash(x), goes straight to the slot where x would be and
compares only the few items stored there, so the average cost hardly grows with the size of the set. x in a_list
compares x with each item in turn and, when x is missing, with every item, so its cost grows with the length of
the list. The set loses its edge in three cases: when the collection is tiny and both are fast anyway; when you
search only once, because building the set takes a full pass over the list first; and when many items have equal
hashes, which makes lookups slow down towards a scan. It also needs hashable items, so a list of lists cannot become a
set at all.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A file of 5 million lines holds one user ID per line, many of them repeated. Write each ID out once, in the order first seen, and report how many lines were repeats. Which structure do you use, how much memory does it need, and what changes if each line is a JSON record instead?
-
Fresher to mid
A unit test checks that str(active_users()) equals "{'asha', 'ravi', 'mei'}", where active_users() returns a set. It passes on a developer's laptop and fails in CI now and then. Explain the cause, why a similar test on a set of small integers never failed, and fix the test.
Key takeaways
- A set holds distinct, hashable values with no order,
set()is the empty set because{}is a dictionary, andremove()raisesKeyErrorfor a missing value whereadd()anddiscard()never complain. |,&,-and^give the union, intersection, difference and symmetric difference of two sets,<=tests for a subset, and the named methods also accept lists.x in slooks only where the hash ofxleads, so it takes about the same time for any size of set, whilex in a_listchecks the items one by one.- String hashes are salted per run, so a set of strings can print in another order every time: sort a set before
showing it, and compare sets with
==. list(dict.fromkeys(items))removes duplicates and keeps the first-seen order, whichset(items)forgets.frozensetis hashable and immutable, so it can be a dictionary key or an item of another set.
Terms in this lesson
- hash randomisation
- The random value Python mixes into the hashes of str and bytes objects, chosen afresh when each process starts, so a set of strings can come out in another order on each run. PYTHONHASHSEED fixes it.
- subset
- A set whose every item is also in another set. a <= b tests it; a < b also needs b to hold something more, which makes a a proper subset.
- symmetric difference
- The items that are in exactly one of two sets, a ^ b: everything in either set except what the two share. {1, 2} ^ {2, 3} is {1, 3}.
References
- The Python Tutorial: Sets (Python Software Foundation)
- Built-in Types: Set Types, set, frozenset (Python Software Foundation)
- Data model: object.__hash__() (Python Software Foundation)
- Built-in Types: Hashing of numeric types (Python Software Foundation)
- Command line and environment: PYTHONHASHSEED (Python Software Foundation)
- Glossary: hashable (Python Software Foundation)
- Design and History FAQ: How are dictionaries implemented in CPython? (Python Software Foundation)
- Time complexity of operations on built-in types: set (Python Software Foundation)
- timeit: Measure execution time of small code snippets (Python Software Foundation)
- Built-in Types: dict.fromkeys() (Python Software Foundation)
- What's New In Python 3.14: Improved error messages (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress