PythonMastery
beginner 18 min read · lesson 14 of 19 in Python Fundamentals

Dictionaries

1 · The lesson

read

A 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

python
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

python
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.

python
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

python
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

python
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

python
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

python
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.

python
# 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.

python
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.

python
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.

python
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

python
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 result

On 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.

python
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.

python
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 OK

If 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().

python
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 faster

3. Mutating a dict while iterating it.

python
d = {"a": 1, "b": 2, "c": 3}
for key in d:
    if d[key] == 2:
        del d[key]              # RuntimeError: dictionary changed size during iteration

Loop 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.

python
# 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.

python
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:

python
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 what setdefault does. defaultdict(list) does the same thing, and the code is even cleaner.
Hint 2: Why convert at the end? A defaultdict 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
python
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:

python
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, but d.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.
  • setdefault and collections.defaultdict handle the get-or-create pattern. Counter handles counting.
  • Dict comprehensions: {k: f(v) for k, v in d.items() if cond}.
  • Merge with {**a, **b} or a | 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.in

Short 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.