Dictionaries
1 · The lesson
readA dictionary maps keys to values. Where a list answers "what's at position 3?", a dict answers "what's the value for 'username'?". A dict finds the answer in about the same time, however big the dict gets.
Python uses dicts in many places. JSON data, keyword arguments (**kwargs), the attributes of most objects, configuration and caches are all stored as dicts.
1. What a Dict Is
user = {
"name": "Alice",
"age": 28,
"languages": ["Python", "TypeScript"],
}
print(user["name"]) # 'Alice'
print(user["languages"]) # ['Python', 'TypeScript']Three properties to remember:
- Key-based: you look things up by key, not by position.
- Hash-backed: lookup, insertion and deletion are O(1) on average.
- Insertion-ordered: since Python 3.7, a loop over a dict gives the keys in the order they were added.
Keys must be hashable: strings, numbers, booleans, and tuples whose items are hashable. Lists and dicts can't be keys.
2. Creating Dicts
a = {} # empty dict
b = {"name": "Ada", "age": 36} # written out directly (most common)
c = dict(name="Linus", age=54) # keyword form (keys must be valid variable names)
d = dict([("x", 1), ("y", 2)]) # from a list of pairs
e = dict(zip(["a", "b", "c"], [1, 2, 3])) # pair up two lists with zip
print(b, c, d, e)Note: {} is an empty dict, not an empty set. The empty set is set(). The Sets lesson comes soon.
3. Reading Values
There are two ways, and the choice matters.
user = {"name": "Alice", "age": 28}
print(user["name"]) # 'Alice'
# print(user["email"]) # KeyError: 'email'
print(user.get("email")) # None (never raises an error)
print(user.get("email", "n/a")) # 'n/a' (the default, used when the key is missing)d[key] means "I expect this key to be here, so raise an error if it isn't." That is often the right behaviour: a missing key is a bug, and you want to know about it.
d.get(key) says "give me this if you have it, otherwise give me nothing." Use it when missing is normal.
Don't use .get() just to hide errors. A KeyError tells you something useful.
4. Setting & Updating
user = {"name": "Alice"}
user["age"] = 28 # add a key
user["name"] = "Alice T" # overwrite a key
print(user) # {'name': 'Alice T', 'age': 28}
user.update({"age": 29, "city": "Hyderabad"})
print(user) # {'name': 'Alice T', 'age': 29, 'city': 'Hyderabad'}update() adds the keys from another dict (or from an iterable of pairs) to this one. If a key exists in both, the new value replaces the old one.
5. Removing Keys
d = {"a": 1, "b": 2, "c": 3}
del d["a"] # raises KeyError if missing
value = d.pop("b") # value == 2; d is now {'c': 3}
missing = d.pop("z", None) # returns None instead of raising
d.clear() # {}Use pop when you want the value and also want to remove it from the dict.
6. Iterating
scores = {"Ada": 95, "Linus": 78, "Grace": 99}
for key in scores: # looping over a dict gives its keys
print(key)
for key in scores.keys(): # explicit, with the same result
print(key)
for value in scores.values():
print(value)
for name, score in scores.items():
print(f"{name} scored {score}").items() is the one you'll use most. The usual style is to unpack each pair directly in the for line, as above.
.keys(), .values() and .items() return views. A view shows the current contents of the dict: if the dict changes, the view changes too. Views are fast to create, because Python doesn't copy the data.
7. Membership
user = {"name": "Alice", "age": 28}
print("name" in user) # True (checks keys, not values)
print("Alice" in user) # False
print("Alice" in user.values()) # True (use .values() when you mean values)x in d always checks keys, and it is fast because it uses the hash. Searching the values is O(n): Python checks them one by one. That is fine once in a while, but not inside a loop that runs many times.
8. setdefault: Get or Create
The pattern "give me the list for this key, and create it if needed" is so common that dicts have a method for it.
# Group words by first letter words = ["apple", "ant", "banana", "berry", "cherry"] groups = {} for word in words: groups.setdefault(word[0], []).append(word) print(groups) # {'a': ['apple', 'ant'], 'b': ['banana', 'berry'], 'c': ['cherry']}
setdefault(key, default) returns the existing value if key is there, or inserts default and returns that. Either way, you get a value back that you can use.
If you use setdefault often, look at defaultdict below. It is cleaner.
9. Counter and defaultdict
Two classes from the collections module are useful from the start.
Counter: count things.
from collections import Counter text = "the quick brown fox jumps over the lazy dog the" counts = Counter(text.split()) print(counts) # Counter({'the': 3, 'quick': 1, 'brown': 1, ...}) print(counts["the"]) # 3 print(counts["missing"]) # 0 (a missing key gives 0, not an error) print(counts.most_common(2)) # [('the', 3), ('quick', 1)]
Counter is a dict subclass, so everything you know about dicts still works.
defaultdict: create missing values automatically.
from collections import defaultdict groups = defaultdict(list) # a missing key gets an empty list automatically for word in ["apple", "ant", "banana", "berry"]: groups[word[0]].append(word) print(dict(groups)) # {'a': ['apple', 'ant'], 'b': ['banana', 'berry']}
You pass the factory (the callable, not the value): list, int, set, or your own function. defaultdict(int) is the usual way to count things when you want a plain dict style object instead of a Counter.
10. Dict Comprehensions
Same idea as list comprehensions, but you produce key-value pairs.
nums = [1, 2, 3, 4] squares = {n: n * n for n in nums} print(squares) # {1: 1, 2: 4, 3: 9, 4: 16} # Invert a dict (assuming unique values) original = {"a": 1, "b": 2, "c": 3} inverted = {v: k for k, v in original.items()} print(inverted) # {1: 'a', 2: 'b', 3: 'c'} # Filter prices = {"apple": 1.0, "steak": 12.0, "bread": 2.5} cheap = {k: v for k, v in prices.items() if v < 5} print(cheap) # {'apple': 1.0, 'bread': 2.5}
11. Merging Dicts
defaults = {"theme": "dark", "font_size": 14}
overrides = {"font_size": 16, "language": "en"}
merged = {**defaults, **overrides} # works on all modern Python versions
print(merged) # {'theme': 'dark', 'font_size': 16, 'language': 'en'}
merged2 = defaults | overrides # cleaner, needs Python 3.9+
print(merged2) # same resultOn key collision, the right-hand dict wins. That is why overrides goes second: the user's settings replace the defaults.
Modern code usually prefers the | operator. It reads like "defaults, with overrides applied".
12. Keys Without Values: Sets
A set is like a dict with keys but no values. It holds unique, hashable items.
seen = {"apple", "banana", "apple"}
print(seen) # {'apple', 'banana'}
print("apple" in seen) # True (a fast check, even for a big set)Use a set when you need a fast in test, or when you need unique items with no values attached. The Sets lesson covers them in full.
Common Mistakes
1. KeyError vs .get(): using the wrong one.
config = {"host": "localhost"}
port = config["port"] # KeyError, and that is often what you want!
port = config.get("port", 5432) # quiet default: use it only when a missing key is really OKIf you hide a missing required setting with
.get(), you get a None that causes an error three function calls later, which is much harder to debug. Let the KeyError happen on the line where the problem is.
2. Iterating .keys() when you wanted .items().
scores = {"Ada": 95, "Linus": 78}
for name in scores:
print(name, scores[name]) # works, but it looks up the key a second time
for name, score in scores.items():
print(name, score) # cleaner and faster3. Mutating a dict while iterating it.
d = {"a": 1, "b": 2, "c": 3}
for key in d:
if d[key] == 2:
del d[key] # RuntimeError: dictionary changed size during iterationLoop over a list of the keys instead:
for key in list(d):. That list is a separate copy, so deleting from the dict doesn't affect it.
4. Using a list as a key.
# d = {[1, 2]: "oops"} # TypeError: unhashable type: 'list' d = {(1, 2): "fine"} # tuples work, because they are hashable
Anything mutable can't be a key. Use a tuple of immutables instead.
5. Assuming dict order doesn't matter.
Since Python 3.7, dicts preserve insertion order. Code that relies on this is fine on modern Python. But note that two dicts with the same keys and values, added in a different order, still compare equal (== ignores order). Order matters for iteration, but it does not matter for equality.
🎯 Your Turn: Build group_by
Write group_by(items, key_fn) that takes a list and a function. It calls key_fn(item) on each item to produce a group key, and returns a dict that maps each key to the list of items that produced it. Keep the items in their original order.
group_by([1, 2, 3, 4, 5, 6], lambda n: n % 2) # {1: [1, 3, 5], 0: [2, 4, 6]} group_by(["apple", "ant", "berry", "banana", "cherry"], lambda s: s[0]) # {'a': ['apple', 'ant'], 'b': ['berry', 'banana'], 'c': ['cherry']} group_by([], lambda x: x) # {}
setup added so this can run · defines group_by
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def group_by(*_a, **_kw): print('-> group_by() called') return _AutoMock('group_by()')
It works like SQL's GROUP BY, but for Python lists. Real programs use it often: to group orders by customer, log lines by level or students by grade.
Skeleton:
def group_by(items, key_fn): # TODO 1: start with an empty container # TODO 2: for each item, compute its key, append the item to that key's list # TODO 3: return a plain dict ... print(group_by([1, 2, 3, 4, 5, 6], lambda n: n % 2))
Hint 1: The "get or create the list" pattern
You need "give me the list for this key, creating an empty one if it doesn't exist, then append". That is exactly whatsetdefault does. defaultdict(list) does the same thing, and the code is even cleaner.
Hint 2: Why convert at the end?
Adefaultdict behaves like a dict in almost every way, but printing one (and comparing in tests) shows the defaultdict(<class 'list'>, ...) wrapper. Wrapping the result in dict(...) before returning gives callers a plain dict. It prints more clearly, and it won't silently create new keys when the caller reads a missing key.
Show full solution
from collections import defaultdict def group_by(items, key_fn): groups = defaultdict(list) for item in items: groups[key_fn(item)].append(item) return dict(groups) # Sanity checks print(group_by([1, 2, 3, 4, 5, 6], lambda n: n % 2)) # {1: [1, 3, 5], 0: [2, 4, 6]} print(group_by(["apple", "ant", "berry", "banana", "cherry"], lambda s: s[0])) # {'a': ['apple', 'ant'], 'b': ['berry', 'banana'], 'c': ['cherry']} print(group_by([], lambda x: x)) # {}
The function is four lines. With defaultdict(list), you don't need to check whether the key exists yet, because append always works. Converting to a plain dict at the end keeps the return type predictable for callers.
The setdefault version is one line longer and equally fine:
def group_by(items, key_fn): groups = {} for item in items: groups.setdefault(key_fn(item), []).append(item) return groups
Either is idiomatic. Choose defaultdict when you will add to the groups in several loops. Choose setdefault for a single grouping step when you don't want an import.
What You Learned
- A dict is a hash-backed, insertion-ordered, key→value mapping. Lookup, insert, and delete are O(1) average.
- Keys must be hashable: strings, numbers, tuples of hashable items. Lists and dicts cannot be keys.
d[key]raises on missing, butd.get(key, default)doesn't. Choose based on whether missing is a bug or a normal case.- Iterate with
.items()for key-value pairs,.keys()or.values()when you only need one side. setdefaultandcollections.defaultdicthandle the get-or-create pattern.Counterhandles counting.- Dict comprehensions:
{k: f(v) for k, v in d.items() if cond}. - Merge with
{**a, **b}ora | b(Python 3.9+). If a key is in both, the value from the right-hand dict is kept.
Next: Modules & Imports, for splitting your code into files and using code that other people wrote.
Practice this
on practicepython.inShort exercises that run in your browser and tell you what your code did, as well as whether a test passed.
Found this useful? Share it with a friend who’s learning Python.