You have met the list: an ordered row of things you reach by position, items[0], items[1]. It is the right tool when order is the point. But most real data is not “the third thing” — it is “the thing called port”, “the user with ID u42”, “how many times the word the appeared”. You want to look things up by name, not by number.
That is a dictionary — Python’s dict — and it is the most important data structure in the language. Python is built out of dicts: every module’s globals, every object’s attributes, every set of keyword arguments is a dict underneath, and JSON from a web API arrives as one. Its close cousin the set is the same machinery with the values thrown away: a bag of unique things, brilliant at exactly two jobs — answering “is this in there?” instantly, and removing duplicates. This lesson covers both, plus the one idea that explains their speed: hashing.
Why this matters
Here is the problem a dict solves. Suppose you have a million user IDs in a list and you want to know whether "u999999" is among them. Python must start at the front and compare every element until it finds a match or falls off the end. A million comparisons. Put those IDs in a set and ask again: Python computes one number from the ID, jumps straight to where it would live, and looks. One hop. On my laptop that is ~4,700 microseconds versus a twentieth of one — tens of thousands of times faster, and the gap widens as the data grows. You will measure it yourself later.
That is not a micro-optimisation; it is the difference between a script that finishes and one you kill after twenty minutes. The most common performance bug in beginner Python is a loop containing if x in some_big_list: — an innocent line that quietly makes your program quadratic.
Dicts also fix a correctness problem. Real data is missing things: the config has no port, the API omits address. Reach for config["port"] and your program dies with KeyError. Reach for config.get("port", 8080) and it carries on. Half this lesson is that difference — the crash and the fix.
The mental model: a list is a numbered row of lockers; a dict is a coat check. With lockers you must remember the number, and finding an item without it means opening every locker. With a coat check you hand over a ticket and get your coat back immediately, however many coats are behind the counter. The ticket is the key, the coat is the value, and the “immediately” is what the second half explains.
The dict: building one, and what a key is allowed to be
A dict is a collection of key → value pairs written in curly braces with colons. The key is how you find things; the value is what you get back.
# A server config: keys are names (strings), values are anything at all.
server = {"host": "web01", "port": 8080, "tls": True}
print(server) # => {'host': 'web01', 'port': 8080, 'tls': True}
print(server["host"]) # => web01
print(len(server)) # => 3 (number of pairs, not characters)
Values can be any type — including lists and other dicts. Keys are fussier, and we will get to why. There are several ways to build a dict, and you will meet all of them in real code:
| How | Example | Result | When you’d use it |
|---|---|---|---|
| Literal | {"host": "web01", "port": 8080} |
{'host': 'web01', 'port': 8080} |
The default — 95% of the time |
| Empty then fill | d = {} then d["host"] = "web01" |
{'host': 'web01'} |
Building up in a loop |
dict() keywords |
dict(host="web01", port=8080) |
{'host': 'web01', 'port': 8080} |
Keys that are valid identifiers; no quotes needed |
| From pairs | dict([("host", "web01"), ("port", 8080)]) |
{'host': 'web01', 'port': 8080} |
You already have a list of 2-tuples |
| From two lists | dict(zip(["host", "port"], ["web01", 8080])) |
{'host': 'web01', 'port': 8080} |
Zipping column names to values (CSV headers!) |
dict.fromkeys() |
dict.fromkeys(["a", "b"], 0) |
{'a': 0, 'b': 0} |
Same starting value for many keys |
| Comprehension | {k: len(k) for k in ["ab", "c"]} |
{'ab': 2, 'c': 1} |
Building from another collection |
⚠️
dict.fromkeys(keys, [])gives every key the same list object, not a copy.d = dict.fromkeys(["a", "b"], []); d["a"].append(1)leaves you with{'a': [1], 'b': [1]}— appending to one appended to “both”, because there is only one list. Only usefromkeyswith an immutable default like0,Noneor"". For mutable defaults, usedefaultdict(list), which you will meet shortly.
Keys must be hashable
Try to use a list as a key and Python refuses:
seen = {}
seen[["a", "b"]] = 1
Traceback (most recent call last):
File "/home/you/tb2.py", line 2, in <module>
seen[["a", "b"]] = 1
~~~~^^^^^^^^^^^^
TypeError: unhashable type: 'list'
Hashable means the object can be fed to the built-in hash() function and produces an integer that never changes for its lifetime. Python needs that number to decide where to file the entry. If a key’s hash could change after filing, the value would be stranded at an address nothing points to — so Python forbids mutable types as keys. The rule of thumb: immutable = hashable = usable as a key.
print(hash(42)) # => 42 (small ints hash to themselves)
print(hash(True)) # => 1
print(hash((1, 2))) # => a big int, built from the items' own hashes
print(hash([1, 2])) # TypeError: unhashable type: 'list'
| Type | Hashable? | Valid key? | Why |
|---|---|---|---|
str — "host" |
Yes | ✅ | Immutable. The overwhelmingly common key type |
int / float / bool |
Yes | ✅ | Immutable numbers |
tuple of immutables — (1, 2) |
Yes | ✅ | Immutable — great for composite keys like (lat, lon) |
frozenset |
Yes | ✅ | The immutable set |
None |
Yes | ✅ | Legal, occasionally handy as a sentinel |
list — [1, 2] |
No | ❌ | Mutable → TypeError: unhashable type: 'list' |
dict |
No | ❌ | Mutable → TypeError: unhashable type: 'dict' |
set |
No | ❌ | Mutable → use frozenset instead |
tuple containing a list — (1, [2]) |
No | ❌ | A tuple is only hashable if everything inside is |
That last row catches people: a tuple is usually a safe key, but hash((1, [2])) still raises TypeError: unhashable type: 'list' — hashability is contagious through the contents. The fix is always to convert the inner list to a tuple.
# Want a coordinate as a key? Use a tuple, not a list.
grid = {(0, 0): "origin", (1, 2): "treasure"}
print(grid[(1, 2)]) # => treasure
Reading and writing: d[k], .get(), and every method that matters
Writing is simple: assign to a key. If it exists you overwrite it; if it doesn’t, you create it. No “append”, no error either way.
server = {"host": "web01"}
server["port"] = 8080 # key didn't exist -> created
server["host"] = "web02" # key existed -> overwritten
print(server) # => {'host': 'web02', 'port': 8080}
Reading is where beginners get hurt. server["hostname"] (note the typo) does not return None or an empty string — it raises:
Traceback (most recent call last):
File "/home/you/tb1.py", line 2, in <module>
print(config["hostname"])
~~~~~~^^^^^^^^^^^^
KeyError: 'hostname'
KeyError is Python saying “there is no such key.” The message is the key it couldn’t find — your best debugging clue, because it is usually a typo, a case difference ("Host" vs "host"), or a field the API didn’t send. The cure is .get():
config = {"host": "web01"}
print(config["port"]) # KeyError: 'port' <- crash
print(config.get("port")) # => None <- no crash
print(config.get("port", 8080))# => 8080 <- no crash, useful default
print(config) # => {'host': 'web01'} <- .get NEVER inserts
These four reads are the whole vocabulary, and choosing between them is a real decision:
| You write | Key exists | Key missing | Mutates the dict? | Use it when |
|---|---|---|---|---|
d[k] |
Returns value | KeyError |
No | The key must be there — you want the crash if it isn’t |
d.get(k) |
Returns value | Returns None |
No | Missing is fine and None is a sensible “nothing” |
d.get(k, default) |
Returns value | Returns default |
No | Missing is fine and you have a fallback (the workhorse) |
d.setdefault(k, default) |
Returns value | Returns default and inserts it |
Yes, if missing | You want the key to exist from now on |
k in d |
True |
False |
No | You only need to know, not to read |
setdefault is the odd one — it reads and writes:
cfg = {"host": "web01"}
print(cfg.setdefault("port", 8080)) # => 8080 (missing, so it inserts)
print(cfg) # => {'host': 'web01', 'port': 8080}
print(cfg.setdefault("port", 9999)) # => 8080 (present, so 9999 is IGNORED)
The name reads backwards to most people. Say it as “give me this key, creating it with this default if it’s absent.” It never overwrites an existing value.
One more trap worth flagging early: in checks keys, not values.
cfg = {"host": "web01"}
print("host" in cfg) # => True (a key)
print("web01" in cfg) # => False (a value! not found)
print("web01" in cfg.values()) # => True (this is how you search values)
The full method table
Here is every dict method you will actually use, with what it returns and how it fails:
| Method / syntax | What it does | Returns | On a missing key |
|---|---|---|---|
d[k] |
Read | The value | KeyError |
d[k] = v |
Insert or overwrite | — | Never fails (creates) |
d.get(k, default=None) |
Safe read | Value or default |
Returns default |
d.setdefault(k, default=None) |
Read, inserting default if absent | Value or default |
Inserts default |
d.pop(k) |
Remove and return | The value | KeyError |
d.pop(k, default) |
Remove and return | Value or default |
Returns default |
d.popitem() |
Remove and return the last inserted pair (LIFO, 3.7+) | (key, value) |
KeyError: 'popitem(): dictionary is empty' |
del d[k] |
Remove | — | KeyError |
d.clear() |
Remove everything | None |
— |
d.copy() |
Shallow copy | New dict | — |
d.update(other, **kw) |
Merge other in, overwriting on clash |
None (in-place!) |
— |
d.keys() |
Live view of keys | dict_keys |
— |
d.values() |
Live view of values | dict_values |
— |
d.items() |
Live view of (k, v) pairs |
dict_items |
— |
len(d) |
Number of pairs | int |
— |
k in d |
Membership on keys | bool |
— |
a | b |
Merge into a new dict (3.9+) | New dict | — |
a |= b |
Merge b into a in place (3.9+) |
— | — |
dict.fromkeys(keys, v) |
New dict, all keys → same v |
New dict | — |
Watch the return values — this is a classic beginner trip-up. update() and clear() return None, because they change the dict in place. d = d.update(other) throws away your dict and leaves you with None. Meanwhile | returns a new dict and leaves both originals alone.
defaults = {"host": "localhost", "port": 8080}
user = {"port": 9000}
print(defaults | user) # => {'host': 'localhost', 'port': 9000} right side wins
print(user | defaults) # => {'port': 8080, 'host': 'localhost'} order matters!
print({**defaults, **user})# => {'host': 'localhost', 'port': 9000} same, works pre-3.9
print(defaults) # => {'host': 'localhost', 'port': 8080} untouched
| Merge style | Version | In place? | Clash winner | Note |
|---|---|---|---|---|
a | b |
3.9+ | No — new dict | b |
Cleanest modern syntax |
a |= b |
3.9+ | Yes | b |
Like a.update(b) |
a.update(b) |
All | Yes | b |
Returns None, not the dict |
{**a, **b} |
3.5+ | No — new dict | b |
Works on older Pythons; also merges into a literal |
Iterating: the trap that catches everyone
Loop over a dict and you get the keys — not the values, and not the pairs. This surprises nearly every beginner:
server = {"host": "web01", "port": 8080, "tls": True}
for x in server:
print(x)
host
port
tls
If you expected web01, 8080, True, this is the moment to rewire: for x in d means for x in d.keys(). The idiomatic way to get both halves is .items(), and you should reach for it by reflex:
for key, value in server.items():
print(f"{key:<5} = {value}")
host = web01
port = 8080
tls = True
| You write | You get each pass | Example first value |
|---|---|---|
for k in d: |
Keys (the default) | 'host' |
for k in d.keys(): |
Keys — explicit, same thing | 'host' |
for v in d.values(): |
Values only | 'web01' |
for k, v in d.items(): |
(key, value) unpacked into two names |
'host', 'web01' |
for i, k in enumerate(d): |
A counter plus the key | 0, 'host' |
for k in sorted(d): |
Keys in sorted order | 'host' (alphabetical) |
Those three view objects — keys(), values(), items() — are live windows, not snapshots. Change the dict and the view already knows:
srv = {"host": "web01"}
ks = srv.keys()
srv["port"] = 8080
print(ks) # => dict_keys(['host', 'port']) <- the view updated itself
A bonus most people never learn: keys() behaves like a set, so you can do algebra directly on it. (values() cannot — values needn’t be unique or hashable.)
a = {"host": 1, "port": 2, "tls": 3}
b = {"host": 9, "user": 8}
print(a.keys() & b.keys()) # => {'host'} keys in BOTH
print(sorted(a.keys() - b.keys())) # => ['port', 'tls'] keys only in a (sorted = stable output)
Insertion order is guaranteed
Since Python 3.7 a dict remembers the order you inserted keys and iteration replays it (an accident of CPython 3.6 that became a language promise in 3.7). It is insertion order — not sorted, not random:
d = {}
d["z"] = 1; d["a"] = 2; d["m"] = 3
print(list(d)) # => ['z', 'a', 'm'] insertion order, not alphabetical
print(sorted(d)) # => ['a', 'm', 'z'] sort explicitly if you want sorted
Two consequences. Any tutorial claiming “dicts are unordered” predates 3.7 and is out of date. And == still ignores order: {"a": 1, "b": 2} == {"b": 2, "a": 1} is True — order is preserved for iteration, but is not part of a dict’s identity.
Never resize a dict you are iterating
This one raises a genuinely confusing error:
counts = {"a": 1, "b": 2, "c": 3}
for key in counts:
if counts[key] < 2:
del counts[key] # deleting DURING iteration
Traceback (most recent call last):
File "/home/you/tb3.py", line 2, in <module>
for key in counts:
RuntimeError: dictionary changed size during iteration
Note where the arrow points: at for key in counts, not at the del line. The iterator is walking the internal table; add or remove an entry and Python may need to resize and reshuffle it, at which point the iterator’s position is meaningless. Rather than hand you silently wrong results, Python raises.
The fix is to iterate over a snapshot, which list() gives you:
counts = {"a": 1, "b": 2, "c": 3}
for key in list(counts): # list() copies the keys FIRST
if counts[key] < 2:
del counts[key]
print(counts) # => {'b': 2, 'c': 3}
Or build a new dict with a comprehension, which is cleaner still:
counts = {"a": 1, "b": 2, "c": 3}
counts = {k: v for k, v in counts.items() if v >= 2}
print(counts) # => {'b': 2, 'c': 3}
Sets do the same thing, with a capital-S message: RuntimeError: Set changed size during iteration. Changing a value in place is fine — only adding or removing keys resizes the table.
Nested dicts: the JSON shape
JSON from any web API arrives as dicts inside dicts inside lists. Reaching deep is easy; reaching deep safely is the skill.
data = {
"user": {"name": "ada", "roles": ["admin"], "address": {"city": "London"}},
"active": True,
}
print(data["user"]["address"]["city"]) # => London
print(data["user"]["roles"][0]) # => admin (list index inside a dict)
Each [...] is an independent lookup, and any one of them can raise KeyError. If the API omits address for some users, data["user"]["address"]["city"] explodes. Chain .get() with {} defaults so every step stays a dict:
print(data.get("user", {}).get("address", {}).get("zip")) # => None
print(data.get("user", {}).get("address", {}).get("zip", "N/A")) # => N/A
print(data.get("acct", {}).get("id")) # => None (whole branch missing)
The {} default is the load-bearing part. Get it wrong and you get a different error that confuses everyone:
print(data.get("acct").get("id"))
# AttributeError: 'NoneType' object has no attribute 'get'
Because data.get("acct") returned None, and None has no .get. If you see 'NoneType' object has no attribute 'get', you forgot a {} default somewhere up the chain.
| Approach | Missing key | Verdict |
|---|---|---|
d["a"]["b"]["c"] |
KeyError |
Fine when the shape is guaranteed |
d.get("a").get("b") |
AttributeError: 'NoneType'… |
Broken — the classic mistake |
d.get("a", {}).get("b", {}).get("c") |
None |
Correct, if verbose — good for 2-3 levels |
try: … except KeyError: |
You handle it | Best when you want to react, not default |
The collections helpers that earn their keep
The standard library’s collections module ships dict subclasses that delete whole paragraphs of your code. Two are genuinely worth learning now.
defaultdict takes a factory (a function it calls to make a value) and runs it automatically whenever you touch a missing key — so you never write the “is it there yet?” dance:
from collections import defaultdict
# Without: you must create the list before appending to it.
groups = {}
for name, dept in [("ada", "eng"), ("bob", "ops"), ("cy", "eng")]:
if dept not in groups:
groups[dept] = []
groups[dept].append(name)
# With: the empty list appears on demand.
groups = defaultdict(list)
for name, dept in [("ada", "eng"), ("bob", "ops"), ("cy", "eng")]:
groups[dept].append(name)
print(groups) # => defaultdict(<class 'list'>, {'eng': ['ada', 'cy'], 'ops': ['bob']})
print(dict(groups)) # => {'eng': ['ada', 'cy'], 'ops': ['bob']}
⚠️ The sting: merely reading a missing key inserts it. d["ghost"] on a defaultdict(int) returns 0 and leaves {'ghost': 0} behind, growing your dict as you inspect it. Use .get() when you only want to look.
Counter is a dict built for tallying. Hand it any iterable and it counts:
from collections import Counter
words = "the quick the lazy the dog dog".split()
c = Counter(words)
print(c) # => Counter({'the': 3, 'dog': 2, 'quick': 1, 'lazy': 1})
print(c.most_common(2)) # => [('the', 3), ('dog', 2)]
print(c["missing"]) # => 0 no KeyError, and nothing is inserted
print(c.total()) # => 7 (3.10+)
| Helper | Import | What it gives you | Reach for it when |
|---|---|---|---|
defaultdict(list) |
collections |
Missing key → new [] |
Grouping items under a key |
defaultdict(int) |
collections |
Missing key → 0 |
Counting by hand |
defaultdict(set) |
collections |
Missing key → set() |
Grouping unique items |
Counter |
collections |
Tallies + .most_common() |
Counting anything — the best tool for the job |
OrderedDict |
collections |
Order-sensitive ==, move_to_end() |
Rarely — see below |
ChainMap |
collections |
Search several dicts as one | Layered config (CLI > env > file) |
Counter earns its own table because its extras are what make it worth importing:
| Expression | Result | Note |
|---|---|---|
Counter("hello") |
Counter({'l': 2, 'h': 1, 'e': 1, 'o': 1}) |
Counts characters of a string |
c.most_common(2) |
[('the', 3), ('dog', 2)] |
Sorted by count, descending |
c.most_common() |
All pairs, ranked | No argument = everything |
c["nope"] |
0 |
Never raises KeyError |
c.total() |
7 |
Sum of counts (3.10+) |
c.update(["the"]) |
— | Adds to counts (doesn’t overwrite!) |
Counter(a=3) - Counter(a=1) |
Counter({'a': 2}) |
Counters do arithmetic |
sorted(c.elements()) |
['a', 'a', 'b'] |
Expands counts back into items |
What about OrderedDict? It is now mostly historical. Before 3.7 it was the only way to get a dict that remembered insertion order; plain dicts do that natively today. Two real differences survive: its == is order-sensitive, and it has move_to_end().
from collections import OrderedDict
print({"a": 1, "b": 2} == {"b": 2, "a": 1}) # => True order ignored
print(OrderedDict([("a",1),("b",2)]) == OrderedDict([("b",2),("a",1)])) # => False order matters
Unless you need one of those, use a plain dict.
Dict comprehensions, in passing
You have seen list comprehensions; dicts have the same trick with a key: value body. A full treatment comes later in the course, but you should recognise the shape:
prices = {"apple": 100, "banana": 40, "cherry": 250}
print({k: v * 2 for k, v in prices.items()}) # => {'apple': 200, 'banana': 80, 'cherry': 500}
print({k: v for k, v in prices.items() if v > 50}) # => {'apple': 100, 'cherry': 250}
print({v: k for k, v in prices.items()}) # => {100: 'apple', 40: 'banana', 250: 'cherry'}
That last one — inverting a dict — only works if the values are hashable and unique. Duplicate values silently collapse, keeping the last one.
Sets: uniqueness, membership, and set algebra
A set is an unordered collection of unique, hashable items. Same hash table as a dict, minus the values. If a dict is a coat check, a set is the guest list: it answers “are you on it?” instantly and refuses to write your name twice.
ids = {"u1", "u2", "u1", "u3"}
print(ids) # => {'u3', 'u2', 'u1'} duplicate dropped; YOUR ORDER WILL DIFFER
print(len(ids)) # => 3
print("u2" in ids) # => True
Two things to internalise from that snippet. Duplicates are dropped silently — no error, they just never existed. And the printed order is not something you can rely on; run it again and you may get a different arrangement.
Why the order shuffles between runs. Python salts the hashing of strings with a random per-process seed (a defence against denial-of-service attacks that deliberately collide keys), so the slot a string lands in — and therefore the print order of a string set — genuinely differs each time you run the program. This is also why you should never save a
hash()value to a file or database and expect it to match later. If you need stable output, callsorted(). Sets of small ints happen to look stable becausehash(42) == 42, but do not lean on it.
The {} trap
{} is an empty dict, not an empty set. This is a wart of the syntax, and everyone hits it once:
print(type({})) # => <class 'dict'> <- NOT a set!
print(type(set())) # => <class 'set'> <- the only way to make an empty set
print(type({1, 2})) # => <class 'set'> <- non-empty braces ARE a set
Dicts got the braces first, so the empty set had to make do with set().
| You write | You get | Note |
|---|---|---|
{} |
Empty dict | The trap |
set() |
Empty set | The only way |
{1, 2, 3} |
Set of 3 ints | Braces without colons = set |
{"a": 1} |
Dict | Braces with colons = dict |
set([1, 1, 2]) |
{1, 2} |
Dedupe a list |
set("hello") |
{'h','e','l','o'} |
A string is iterable — 4 unique chars |
{x % 3 for x in range(10)} |
{0, 1, 2} |
Set comprehension |
frozenset([1, 2]) |
frozenset({1, 2}) |
The immutable, hashable version |
Members obey the same hashability rule as keys — {[1, 2]} raises TypeError: unhashable type: 'list'.
Set methods
| Method | Does | If it isn’t there |
|---|---|---|
s.add(x) |
Add one item | Already present → no-op, no error |
s.update(iterable) |
Add many | — |
s.discard(x) |
Remove | Silent — no error |
s.remove(x) |
Remove | KeyError: 99 |
s.pop() |
Remove and return an arbitrary item | KeyError: 'pop from an empty set' |
s.clear() |
Empty it | — |
s.copy() |
Shallow copy | — |
len(s) |
Size | — |
x in s |
Membership — O(1) | — |
discard vs remove is the only real decision: discard when you don’t care, remove when absence is a bug you want to hear about.
There is no indexing, ever — a set has no order to index into:
unique = {"u1", "u2", "u3"}
print(unique[0])
Traceback (most recent call last):
File "/home/you/tb4.py", line 2, in <module>
print(unique[0])
~~~~~~^^^
TypeError: 'set' object is not subscriptable
Need a specific element? You want a list. Need sorted output? sorted(unique) gives you a list back.
Set algebra
This is where sets stop being “lists without duplicates” and start being a superpower. Every operator has a method twin:
| Question | Operator | Method | Example on a={1,2,3,4}, b={3,4,5,6} |
|---|---|---|---|
| Everything in either | a | b |
a.union(b) |
{1, 2, 3, 4, 5, 6} |
| Only what’s in both | a & b |
a.intersection(b) |
{3, 4} |
In a but not b |
a - b |
a.difference(b) |
{1, 2} |
In b but not a |
b - a |
b.difference(a) |
{5, 6} |
| In one but not both | a ^ b |
a.symmetric_difference(b) |
{1, 2, 5, 6} |
Is every a inside b? |
a <= b |
a.issubset(b) |
{1,2} <= a → True |
| …and strictly smaller? | a < b |
— | a < a → False |
Does a contain all of b? |
a >= b |
a.issuperset(b) |
a >= {1,2} → True |
| Zero overlap? | — | a.isdisjoint(b) |
a.isdisjoint({99}) → True |
The difference between operator and method is not cosmetic: operators demand a set on both sides; methods accept any iterable.
a = {1, 2, 3, 4}
print(a.union([7])) # => {1, 2, 3, 4, 7} method takes a list happily
print(a | [7]) # TypeError: unsupported operand type(s) for |: 'set' and 'list'
Which makes the real-world use obvious. Two exports, two lists of IDs, one question — what changed?
old = {"u1", "u2", "u3", "u4"}
new = {"u3", "u4", "u5"}
print("added: ", sorted(new - old)) # => ['u5']
print("removed:", sorted(old - new)) # => ['u1', 'u2']
print("kept: ", sorted(old & new)) # => ['u3', 'u4']
print("churned:", sorted(old ^ new)) # => ['u1', 'u2', 'u5']
Four lines, and each would be a nested loop otherwise. This is the single most useful thing sets do.
frozenset
A frozenset is a set you cannot change — which makes it hashable, which means it can be a dict key or live inside another set:
fs = frozenset([1, 2])
print({fs: "pair"}) # => {frozenset({1, 2}): 'pair'} a set as a dict KEY
print({frozenset([1, 2]), frozenset([3])}) # => {frozenset({3}), frozenset({1, 2})} a set OF sets
fs.add(3) # AttributeError: 'frozenset' object has no attribute 'add'
Use it when you need a set of sets, or a set as a dict key — for example keying a cache on the set of permissions a request carries.
Why dicts and sets are fast: hashing and Big-O
Now the payoff. When you write d["eth0"], Python does four things: it calls hash("eth0") to get an integer; it masks that integer down to a slot number in the table (roughly hash & (size - 1)); it jumps straight to that slot; and it compares the key there with == to confirm. That is a constant number of steps — the dict could hold ten entries or ten million and it is the same four steps. That is what O(1) means: the cost does not grow with the data.
A list has no such trick. x in my_list starts at index 0 and compares its way along until it finds x or runs out — O(n), cost proportional to size. The diagram below traces both paths side by side; a set is the same machine as the dict with the value column removed, which is why it inherits the O(1).
The badges mark what matters to you as a learner: a key must be hashable (1), hash() turns it into an int (2) — which is exactly why a mutable list cannot be a key (3); the int becomes a slot address, with probing when two keys collide (4); the value comes back in one hop, O(1) average (5); while a list has to look at everything, O(n) (6).
Here is the honest complexity table. “Average” matters — a dict’s O(n) worst case needs a pathological set of keys that all collide into one slot, which you will never see by accident:
| Operation | dict / set |
list |
Why |
|---|---|---|---|
x in c |
O(1) avg (O(n) worst) | O(n) | Hash and jump vs scan everything |
Get by key d[k] |
O(1) avg | n/a | — |
Get by index l[3] |
n/a | O(1) | Lists are great at this — dicts can’t do it |
| Insert / add | O(1) avg | O(1) amortised (append) |
Occasional resize is amortised away |
| Insert at the front | n/a | O(n) (insert(0, x)) |
Everything shifts up one |
| Delete by key/value | O(1) avg | O(n) (remove) |
The list must find it first |
| Iterate all | O(n) | O(n) | Same — you touch everything either way |
| Get the smallest | O(n log n) via sorted() |
O(n log n) via sorted() |
Neither is sorted; use min() for O(n) |
| Memory for 1,000 ints | ~33 KB set / ~37 KB dict | ~8 KB | ~4.5× the memory — that’s the price of O(1) |
That last row is the trade-off: hash tables buy speed with empty space (the table must stay partly empty or collisions pile up). For a thousand items, who cares; for a hundred million, measure.
And the caveat folklore omits: O(n) is the worst case for a list. If the item sits at index 0 the list wins — ~28 ns, no hashing at all. Sets are not magic; they are consistently fast rather than sometimes fast. Below ~10 items the difference is noise, so use whatever reads better. Above a few hundred, in a loop, the set is the only sane answer.
Hands-on lab
Everything here is pure standard library — there is nothing to pip install. You need Python 3.10+ (Counter.total() needs 3.10; everything else works on 3.7+). If you like keeping work isolated, a venv costs nothing:
python3 -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
python3 --version # => Python 3.12.3 (yours may differ)
mkdir dictlab && cd dictlab
Step 1 — A word-frequency counter, three ways
Create word_freq.py:
"""Word frequency, three ways — they must all agree."""
from collections import defaultdict, Counter
TEXT = """the quick brown fox jumps over the lazy dog
the dog barks and the fox runs the fox wins"""
words = TEXT.split()
print(f"{len(words)} words, {len(set(words))} unique")
# --- Way 1: plain dict + .get(k, 0) ---------------------------------
counts_get = {}
for w in words:
counts_get[w] = counts_get.get(w, 0) + 1
# --- Way 2: defaultdict(int) ----------------------------------------
counts_dd = defaultdict(int)
for w in words:
counts_dd[w] += 1
# --- Way 3: Counter -------------------------------------------------
counts_ctr = Counter(words)
# All three produce the same mapping
print("way1 == way2:", counts_get == counts_dd)
print("way2 == way3:", counts_dd == counts_ctr)
print("way1 == way3:", counts_get == counts_ctr)
print("\ntop 3 (Counter.most_common):")
for word, n in counts_ctr.most_common(3):
print(f" {word:<6} {'#' * n} {n}")
# The KeyError a beginner writes first:
try:
bad = {}
for w in words:
bad[w] = bad[w] + 1
except KeyError as ex:
print(f"\nnaive bad[w] + 1 -> KeyError: {ex}")
Run it:
python3 word_freq.py
19 words, 12 unique
way1 == way2: True
way2 == way3: True
way1 == way3: True
top 3 (Counter.most_common):
the ##### 5
fox ### 3
dog ## 2
naive bad[w] + 1 -> KeyError: 'the'
What just happened: three different tools produced identical dicts — counts_get == counts_dd == counts_ctr is True, because defaultdict and Counter are dicts (subclasses), so they compare equal to a plain dict with the same pairs. The last block is the bug you’d write on day one: bad[w] + 1 reads a key that does not exist yet, so the very first word raises KeyError: 'the'. .get(w, 0) fixes it by supplying the “not seen yet” default; defaultdict(int) fixes it by manufacturing the 0 on demand; Counter fixes it by being purpose-built. For counting, always use Counter — the other two are what you use when the values aren’t counts.
Step 2 — Diff two lists of user IDs
Create id_diff.py:
"""Diff two lists of user IDs with set algebra, then dedupe keeping order."""
# Yesterday's export and today's export, straight from two CSVs.
yesterday = ["u1", "u2", "u3", "u4", "u5", "u3"] # note: u3 listed twice
today = ["u3", "u4", "u5", "u6", "u7"]
old, new = set(yesterday), set(today)
print(f"yesterday: {len(yesterday)} rows -> {len(old)} unique")
print(f"today : {len(today)} rows -> {len(new)} unique")
# sorted() because a set has NO order — sort for stable, printable output
print("\nadded (new - old):", sorted(new - old))
print("removed (old - new):", sorted(old - new))
print("common (old & new):", sorted(old & new))
print("churned (old ^ new):", sorted(old ^ new))
print("\nis today a subset of yesterday? ", new <= old)
print("do they overlap at all? ", not old.isdisjoint(new))
python3 id_diff.py
yesterday: 6 rows -> 5 unique
today : 5 rows -> 5 unique
added (new - old): ['u6', 'u7']
removed (old - new): ['u1', 'u2']
common (old & new): ['u3', 'u4', 'u5']
churned (old ^ new): ['u1', 'u2', 'u6', 'u7']
is today a subset of yesterday? False
do they overlap at all? True
What just happened: set(yesterday) turned 6 rows into 5 unique IDs — the duplicate u3 vanished without a word. Then four set operations answered a real question (“who joined, who left, who stayed, what churned?”) in four lines with no loops at all. The sorted() calls are not decoration: without them the order would differ between runs.
Step 3 — Dedupe while preserving order
Append this to id_diff.py and re-run:
print("\n-- dedupe --")
print("set() drops duplicates but LOSES order:")
print(" ", set(yesterday), "<- run again, different order!")
print("dict.fromkeys() drops duplicates and KEEPS first-seen order:")
print(" ", list(dict.fromkeys(yesterday)))
# manual, order-preserving dedupe (what fromkeys does under the hood)
seen = set()
ordered = []
for uid in yesterday:
if uid not in seen: # O(1) membership test
seen.add(uid)
ordered.append(uid)
print("manual seen-set version:")
print(" ", ordered)
print("same as fromkeys:", ordered == list(dict.fromkeys(yesterday)))
-- dedupe --
set() drops duplicates but LOSES order:
{'u4', 'u2', 'u3', 'u1', 'u5'} <- run again, different order!
dict.fromkeys() drops duplicates and KEEPS first-seen order:
['u1', 'u2', 'u3', 'u4', 'u5']
manual seen-set version:
['u1', 'u2', 'u3', 'u4', 'u5']
same as fromkeys: True
What just happened: run this twice and the set() line prints its five IDs in a different order each time (string hash salting), while the two ordered versions never budge. dict.fromkeys(seq) is the idiomatic order-preserving dedupe — it exploits the fact that dict keys are unique and insertion-ordered since 3.7. The manual seen loop shows what it’s doing underneath, and is the pattern to reach for when you need to dedupe on a computed key (e.g. lowercase the email before comparing).
Step 4 — Prove O(1) vs O(n) with timeit
Create bench_lab.py:
"""Prove O(1) vs O(n) with timeit. Numbers vary by machine; the RATIO is the point."""
import timeit
SETUP = """
n = {n}
haystack_list = list(range(n))
haystack_set = set(haystack_list)
needle = n - 1 # worst case for the list: the very last item
"""
print(f"{'n':>10} | {'list (us)':>12} | {'set (us)':>9} | {'set is':>12}")
print("-" * 52)
for n in (1_000, 10_000, 100_000, 1_000_000):
setup = SETUP.format(n=n)
reps = 200 if n <= 10_000 else 20
t_list = timeit.timeit("needle in haystack_list", setup=setup, number=reps) / reps
t_set = timeit.timeit("needle in haystack_set", setup=setup, number=reps) / reps
print(f"{n:>10,} | {t_list*1e6:>12.2f} | {t_set*1e6:>9.3f} | {t_list/t_set:>10,.0f}x")
print("\nlist grows 10x slower each time (O(n)); set does not move (O(1)).")
# Honest caveat: if the item is at the FRONT, the list is fine.
setup = SETUP.format(n=1_000_000).replace("needle = n - 1", "needle = 0")
t_list = timeit.timeit("needle in haystack_list", setup=setup, number=100) / 100
t_set = timeit.timeit("needle in haystack_set", setup=setup, number=100) / 100
print(f"\nneedle at index 0 of 1M: list {t_list*1e9:.0f} ns vs set {t_set*1e9:.0f} ns")
python3 bench_lab.py # about a second — most of it building the million-item list
n | list (us) | set (us) | set is
----------------------------------------------------
1,000 | 6.27 | 0.023 | 269x
10,000 | 60.25 | 0.021 | 2,892x
100,000 | 554.54 | 0.085 | 6,493x
1,000,000 | 4711.37 | 0.038 | 125,635x
list grows 10x slower each time (O(n)); set does not move (O(1)).
needle at index 0 of 1M: list 28 ns vs set 26 ns
What just happened: your absolute numbers will differ — the shape is the lesson. Read the list column downwards: 6 → 60 → 554 → 4,711. Every time the data got 10× bigger, the search got 10× slower. That is O(n) with your own eyes. Now read the set column: 0.023 → 0.021 → 0.085 → 0.038. It does not move. A thousand items or a million, the set answers in the same breath — O(1). The final line is the honesty check: with the needle at index 0, the list is just as fast, because O(n) is the worst case. Sets aren’t magic, they’re reliable.
Common mistakes and troubleshooting
| Symptom / traceback | Cause | Fix |
|---|---|---|
KeyError: 'port' |
Reading a key that isn’t there — typo, wrong case, or the API omitted it | d.get("port", default); or check if "port" in d: first |
TypeError: unhashable type: 'list' |
Used a list as a dict key or set member | Use a tuple: d[(1, 2)], or frozenset for a set of sets |
TypeError: unhashable type: 'dict' |
Used a dict as a key (often a nested JSON fragment) | Key on something immutable — an ID string, or a tuple of fields |
TypeError: 'set' object is not subscriptable |
Wrote s[0] — sets have no order to index |
sorted(s)[0], or list(s)[0] if any element will do |
RuntimeError: dictionary changed size during iteration |
Added/deleted keys inside for k in d: |
Iterate a snapshot: for k in list(d):, or rebuild with a comprehension |
RuntimeError: Set changed size during iteration |
Same, on a set | for x in list(s):, or build a new set |
AttributeError: 'NoneType' object has no attribute 'get' |
Chained .get() without a {} default; a middle level returned None |
d.get("a", {}).get("b") — every level needs {} |
TypeError: unsupported operand type(s) for +: 'NoneType' and 'int' |
.get() missed and returned None, then you did maths on it |
d.get(k, 0) + 1 — supply a default of the right type |
TypeError: unsupported operand type(s) for |: 'set' and 'list' |
Set operators need sets on both sides | a.union([7]) — the method accepts any iterable |
| Loop over a dict prints keys, not values | for x in d: iterates keys by design |
for k, v in d.items(): |
{} didn’t behave like a set |
{} is an empty dict |
set() is the only way to make an empty set |
| A count is silently wrong; two keys merged into one | True/1/1.0 are equal and hash equal → same key |
Don’t mix bools and numbers as keys; use strings |
Duplicates survived set() |
The items are unequal objects that look alike ("U1" vs "u1", 1 vs "1") |
Normalise first: {s.strip().lower() for s in raw} |
defaultdict grew keys you never added |
Reading a missing key inserts it | Use .get() to inspect without inserting |
Three gotchas deserve more than a table row.
1. .get() returning None silently. .get() fixes KeyError, but it can also hide a bug. A KeyError is loud — it names the key and the line. .get() hands back None and lets your program carry on with nonsense, so the explosion lands somewhere else entirely:
scores = {"ada": 10}
total = scores.get("grace") + 5
Traceback (most recent call last):
File "/home/you/tb5.py", line 2, in <module>
total = scores.get("grace") + 5
~~~~~~~~~~~~~~~~~~~~^~~
TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'
The error says TypeError on line 2, but the real bug is that "grace" was never in scores. Two rules: give .get() a default of the right type (scores.get("grace", 0) + 5 works), and use bare d[k] when a missing key genuinely means broken data — you want that crash. Also beware if d.get(k):, which is False for a key holding 0, "" or False. Ask if k in d: when you mean “is it present?”.
2. True == 1 and the invisible key merge. Python considers 1, 1.0 and True to be the same key, because they compare equal and hash equal. A dict cannot tell them apart:
print(hash(1), hash(1.0), hash(True)) # => 1 1 1
print(1 == 1.0 == True) # => True
d = {1: "int", 1.0: "float", True: "bool"}
print(d) # => {1: 'bool'} <- THREE literals, ONE entry
print(len(d)) # => 1
print({1, 1.0, True, 2}) # => {1, 2}
Three pairs went in and one came out. Note the subtlety: the key stays 1 (the first one inserted is kept) while the value becomes "bool" (the last one wins). Mixing bools and numbers as keys is rare but produces bafflingly silent data loss when it happens. Floats have a related problem — {0.1 + 0.2: "x"} cannot be found with 0.3, because 0.1 + 0.2 is really 0.30000000000000004. Don’t use floats as keys.
3. Mutating while iterating. Worth repeating because the traceback points at the for line, not the line you actually wrote wrong. Any time you want to filter a dict, reach for a comprehension ({k: v for k, v in d.items() if …}) rather than a del inside a loop. It is shorter, faster, and cannot raise RuntimeError.
Cheat-sheet
Dictionaries
| Syntax | Does |
|---|---|
d = {"k": 1} / d = {} |
Create / empty dict |
dict(zip(keys, vals)) |
Build from two parallel lists |
d[k] |
Read — KeyError if missing |
d.get(k) / d.get(k, dflt) |
Safe read — None / dflt if missing |
d.setdefault(k, dflt) |
Read, inserting dflt if missing |
d[k] = v |
Insert or overwrite |
del d[k] / d.pop(k) |
Remove / remove and return |
d.pop(k, None) |
Remove if present, never raise |
d.popitem() |
Remove and return the last-inserted pair |
k in d |
Is k a key? (not a value) |
v in d.values() |
Is v a value? — O(n) |
len(d) / d.clear() / d.copy() |
Size / empty it / shallow copy |
d.update(other) / d |= other |
Merge in place (returns None) |
a | b / {**a, **b} |
Merge into a new dict; right side wins |
for k in d: |
Iterate keys |
for k, v in d.items(): |
Iterate pairs — the idiom |
d.keys() & other.keys() |
Set algebra on keys |
sorted(d) / sorted(d.items(), key=lambda kv: kv[1]) |
Keys sorted / pairs by value |
{k: v for k, v in d.items() if v > 0} |
Filter into a new dict |
Counter(seq).most_common(3) |
Top 3 by frequency |
defaultdict(list)[k].append(x) |
Group without pre-creating lists |
Sets
| Syntax | Does |
|---|---|
s = {1, 2} / s = set() |
Create / empty set ({} is a dict!) |
set(seq) |
Dedupe any iterable (loses order) |
list(dict.fromkeys(seq)) |
Dedupe keeping first-seen order |
s.add(x) / s.update(it) |
Add one / many |
s.discard(x) / s.remove(x) |
Remove quietly / KeyError if absent |
x in s |
Membership — O(1) |
a | b / a.union(b) |
Union |
a & b / a.intersection(b) |
Intersection |
a - b / a.difference(b) |
Difference |
a ^ b / a.symmetric_difference(b) |
In one but not both |
a <= b / a < b |
Subset / proper subset |
a.isdisjoint(b) |
No overlap at all? |
sorted(s) |
Stable, printable order (a list) |
frozenset(s) |
Immutable → hashable → usable as a key |
{x.lower() for x in names} |
Set comprehension |
Interview and exam questions
Q: What’s the difference between a list and a dict, and when do you choose each? A: A list is an ordered sequence indexed by position; a dict maps unique keys to values. Choose a list when order matters and you access by index or iterate everything. Choose a dict when you look things up by a name/ID — that lookup is O(1) for a dict versus O(n) for scanning a list.
Q: Why can’t a list be a dict key?
A: Keys must be hashable — they need a hash() that never changes while the key is in the dict, because the hash decides which slot stores the entry. Lists are mutable, so their hash could change after insertion and the value would be unreachable. Python forbids it up front with TypeError: unhashable type: 'list'. Use a tuple instead.
Q: What’s the difference between d[k], d.get(k) and d.setdefault(k, v)?
A: d[k] returns the value or raises KeyError. d.get(k) returns the value or None (or a default you pass) and never modifies the dict. d.setdefault(k, v) returns the value if present, otherwise inserts v and returns it. Only setdefault mutates.
Q: You iterate a dict with for x in d: — what is x?
A: The keys. for x in d: is for x in d.keys(). For values use d.values(); for both use for k, v in d.items():.
Q: Are dicts ordered?
A: Yes — insertion-ordered since Python 3.7 (an implementation detail of CPython 3.6 that became a language guarantee). Iteration replays insertion order. But == between dicts ignores order, and it’s insertion order, not sorted order. Sets remain genuinely unordered.
Q: Explain why x in my_set is faster than x in my_list.
A: A set hashes x to an integer, masks it to a slot, jumps there and compares — a constant number of steps regardless of size: O(1) average. A list compares element by element from the start: O(n). At a million items that’s ~4,700 µs versus ~0.04 µs. The caveat: O(n) is the list’s worst case; if the item is near the front the list is fine.
Q: What happens when two different keys hash to the same slot?
A: A collision. Python probes other slots in a fixed sequence until it finds a free one (or the matching key), comparing full keys with == to confirm. Correctness is unaffected; a collision just costs extra steps. Pathologically many collisions degrade a dict to O(n), which is why string hashing is randomised per process — to stop attackers engineering it.
Q (coding): Count word frequencies and return the top 3. A:
from collections import Counter
def top3(text: str) -> list[tuple[str, int]]:
return Counter(text.split()).most_common(3)
Without Counter: counts[w] = counts.get(w, 0) + 1 in a loop, then sorted(counts.items(), key=lambda kv: -kv[1])[:3].
Q (coding): Remove duplicates from a list while preserving order. A:
def dedupe(seq: list) -> list:
return list(dict.fromkeys(seq)) # dict keys are unique AND ordered (3.7+)
set(seq) also dedupes but loses order. The manual version uses a seen = set() for O(1) checks — never if x not in result_list, which is O(n) per item and makes the whole thing O(n²).
Q (coding): Given two lists of IDs, find what was added and removed. A:
old, new = set(old_ids), set(new_ids)
added, removed = new - old, old - new
Q: Why does {1: 'a', True: 'b'} have only one entry?
A: 1 == True and hash(1) == hash(True) == 1, so the dict treats them as the same key. The first key inserted is kept (1) and the last value wins ('b'), giving {1: 'b'}. 1.0 joins the same collision. Don’t mix bools, ints and floats as keys.
Q: When would you use a frozenset?
A: When you need a set to be hashable — as a dict key or as a member of another set. For example, caching results keyed by the set of permissions in a request, or building a set of unique groups where each group is itself a set.
Key takeaways
- A dict maps keys to values with O(1) average lookup. It is the workhorse of Python — objects, modules, kwargs and JSON are all dicts underneath. A list is for order; a dict is for lookup by name.
- Keys must be hashable, which in practice means immutable —
str,int,tuple,frozensetare fine;list,dictandsetraiseTypeError: unhashable type. A tuple counts only if everything inside it does too. d[k]raisesKeyError;d.get(k, default)is the fix — but a default of the wrong type just moves the crash. Reach ford[k]when a missing key really is a bug you want to hear about.- Iterating a dict gives you keys.
for k, v in d.items():is the idiom to burn in. Never add or delete keys while iterating — snapshot withlist(d)or rebuild with a comprehension. - Insertion order has been guaranteed since 3.7, which is why
list(dict.fromkeys(seq))is the one-line order-preserving dedupe. Any tutorial calling dicts unordered is out of date. - Use
Counterfor counting anddefaultdictfor grouping — but remember reading a missing key on adefaultdictsilently inserts it.OrderedDictis now mostly historical. {}is an empty dict;set()is the only empty set. Sets are unordered (so no indexing —TypeError: 'set' object is not subscriptable) and their print order for strings genuinely changes between runs. Callsorted()for stable output.- Set algebra replaces nested loops:
|union,&intersection,-difference,^symmetric difference. Operators need sets on both sides; the method twins (.union()) take any iterable. - Hashing is the whole story: key →
hash()→ slot → value, in a constant number of steps no matter the size. That’s O(1) for dicts and sets versus O(n) for a list scan — worth ~100,000× at a million items, paid for with roughly 4.5× the memory.