Dictionaries
In this chapter, we'll explore Python's dictionary data structure using TDD. We'll learn:
- Creating and using dictionaries
- Common dictionary methods
- Dictionary comprehensions
defaultdictandCounterfrom collections- When and how to use dictionaries effectively
What is a dictionary?
A dictionary is a collection of key-value pairs. Keys must be unique and immutable (strings, numbers, tuples), while values can be anything.
First example: Word counter
Let's build a word counter using TDD.
Write the test first
Create test_counter.py:
from counter import count_words
def test_count_words():
text = "the cat sat on the mat"
got = count_words(text)
want = {"the": 2, "cat": 1, "sat": 1, "on": 1, "mat": 1}
assert got == want
Make it compile
Create counter.py:
def count_words(text):
pass
Make it pass
def count_words(text):
"""Count occurrences of each word in text."""
counts = {}
for word in text.split():
if word in counts:
counts[word] += 1
else:
counts[word] = 1
return counts
Refactor with dict.get()
def count_words(text):
"""Count occurrences of each word in text."""
counts = {}
for word in text.split():
counts[word] = counts.get(word, 0) + 1
return counts
The get() method returns the value for a key, or a default if the key doesn't exist.
Dictionary basics
# Creating dictionaries
empty = {}
person = {"name": "Alice", "age": 30}
also_dict = dict(name="Alice", age=30)
# Accessing values
name = person["name"] # "Alice"
age = person.get("age") # 30
city = person.get("city", "Unknown") # "Unknown" (default)
# Modifying
person["age"] = 31 # Update existing
person["city"] = "London" # Add new
# Removing
del person["city"] # Raises KeyError if missing
city = person.pop("city", None) # Returns None if missing
# Checking existence
if "name" in person:
print(person["name"])
Dictionary methods
keys(), values(), items()
person = {"name": "Alice", "age": 30, "city": "London"}
person.keys() # dict_keys(['name', 'age', 'city'])
person.values() # dict_values(['Alice', 30, 'London'])
person.items() # dict_items([('name', 'Alice'), ('age', 30), ('city', 'London')])
Iterating over dictionaries
# Iterate over keys (default)
for key in person:
print(key)
# Iterate over values
for value in person.values():
print(value)
# Iterate over key-value pairs
for key, value in person.items():
print(f"{key}: {value}")
Testing dictionary iteration
def test_format_person():
person = {"name": "Alice", "age": 30}
got = format_person(person)
assert "name: Alice" in got
assert "age: 30" in got
Implementation:
def format_person(person):
"""Format person dictionary as a string."""
lines = [f"{key}: {value}" for key, value in person.items()]
return "\n".join(lines)
Dictionary comprehensions
Like list comprehensions, but for dictionaries:
# Square each number
numbers = [1, 2, 3, 4, 5]
squares = {n: n ** 2 for n in numbers}
# {1: 1, 2: 4, 3: 9, 4: 16, 5: 25}
# Filter with condition
even_squares = {n: n ** 2 for n in numbers if n % 2 == 0}
# {2: 4, 4: 16}
# Transform keys and values
person = {"name": "Alice", "age": 30}
upper_person = {k.upper(): v for k, v in person.items()}
# {"NAME": "Alice", "AGE": 30}
Testing comprehensions
def test_invert_dict():
original = {"a": 1, "b": 2, "c": 3}
got = invert_dict(original)
want = {1: "a", 2: "b", 3: "c"}
assert got == want
Implementation:
def invert_dict(d):
"""Swap keys and values."""
return {v: k for k, v in d.items()}
Merging dictionaries
defaults = {"color": "blue", "size": "medium"}
custom = {"size": "large", "font": "Arial"}
# Python 3.9+
merged = defaults | custom
# {"color": "blue", "size": "large", "font": "Arial"}
# Python 3.5+
merged = {**defaults, **custom}
# Older approach
merged = defaults.copy()
merged.update(custom)
Testing merge
def test_merge_settings():
defaults = {"theme": "light", "font_size": 12}
custom = {"font_size": 14}
got = merge_settings(defaults, custom)
want = {"theme": "light", "font_size": 14}
assert got == want
Implementation:
def merge_settings(defaults, custom):
"""Merge custom settings over defaults."""
return {**defaults, **custom}
defaultdict
defaultdict provides a default value for missing keys:
from collections import defaultdict
# Group items by category
items = [
("fruit", "apple"),
("vegetable", "carrot"),
("fruit", "banana"),
("vegetable", "broccoli"),
]
# Without defaultdict
grouped = {}
for category, item in items:
if category not in grouped:
grouped[category] = []
grouped[category].append(item)
# With defaultdict
grouped = defaultdict(list)
for category, item in items:
grouped[category].append(item)
# Result: {"fruit": ["apple", "banana"], "vegetable": ["carrot", "broccoli"]}
Testing with defaultdict
def test_group_by_first_letter():
words = ["apple", "banana", "apricot", "blueberry"]
got = group_by_first_letter(words)
want = {"a": ["apple", "apricot"], "b": ["banana", "blueberry"]}
assert dict(got) == want
Implementation:
from collections import defaultdict
def group_by_first_letter(words):
"""Group words by their first letter."""
groups = defaultdict(list)
for word in words:
groups[word[0]].append(word)
return groups
Counter
Counter is a specialized dictionary for counting:
from collections import Counter
# Count elements
colors = ["red", "blue", "red", "green", "blue", "red"]
counts = Counter(colors)
# Counter({"red": 3, "blue": 2, "green": 1})
# Most common elements
counts.most_common(2)
# [("red", 3), ("blue", 2)]
# Arithmetic with counters
counter1 = Counter({"a": 3, "b": 1})
counter2 = Counter({"a": 1, "b": 2})
counter1 + counter2 # Counter({"a": 4, "b": 3})
counter1 - counter2 # Counter({"a": 2})
Refactor word counter with Counter
from collections import Counter
def count_words(text):
"""Count occurrences of each word in text."""
return Counter(text.split())
Much simpler! Our tests still pass because Counter is a dict subclass.
Testing Counter features
from collections import Counter
def test_top_words():
text = "the cat and the dog and the bird"
got = top_words(text, 2)
want = [("the", 3), ("and", 2)]
assert got == want
Implementation:
from collections import Counter
def top_words(text, n):
"""Return the n most common words."""
counts = Counter(text.split())
return counts.most_common(n)
Nested dictionaries
Dictionaries can contain other dictionaries:
users = {
"alice": {
"email": "alice@example.com",
"age": 30,
"roles": ["admin", "user"],
},
"bob": {
"email": "bob@example.com",
"age": 25,
"roles": ["user"],
},
}
# Accessing nested values
alice_email = users["alice"]["email"]
# Safe access with get
bob_age = users.get("bob", {}).get("age")
Testing nested access
def test_get_user_email():
users = {
"alice": {"email": "alice@example.com"},
}
assert get_user_email(users, "alice") == "alice@example.com"
assert get_user_email(users, "unknown") is None
Implementation:
def get_user_email(users, username):
"""Get user's email, or None if user doesn't exist."""
user = users.get(username)
if user:
return user.get("email")
return None
Dictionary as a dispatch table
Use dictionaries instead of long if/elif chains:
def test_calculate():
assert calculate(10, 5, "add") == 15
assert calculate(10, 5, "subtract") == 5
assert calculate(10, 5, "multiply") == 50
assert calculate(10, 5, "divide") == 2
Implementation:
def calculate(a, b, operation):
"""Perform a calculation based on operation name."""
operations = {
"add": lambda x, y: x + y,
"subtract": lambda x, y: x - y,
"multiply": lambda x, y: x * y,
"divide": lambda x, y: x / y,
}
if operation not in operations:
raise ValueError(f"Unknown operation: {operation}")
return operations[operation](a, b)
A complete example: Phone book
# test_phonebook.py
import pytest
from phonebook import PhoneBook
def test_add_and_lookup():
book = PhoneBook()
book.add("Alice", "555-1234")
assert book.lookup("Alice") == "555-1234"
def test_lookup_missing():
book = PhoneBook()
assert book.lookup("Unknown") is None
def test_remove():
book = PhoneBook()
book.add("Alice", "555-1234")
book.remove("Alice")
assert book.lookup("Alice") is None
def test_list_all():
book = PhoneBook()
book.add("Alice", "555-1234")
book.add("Bob", "555-5678")
all_entries = book.list_all()
assert ("Alice", "555-1234") in all_entries
assert ("Bob", "555-5678") in all_entries
Implementation:
# phonebook.py
class PhoneBook:
def __init__(self):
self._entries = {}
def add(self, name, number):
"""Add or update a phone number."""
self._entries[name] = number
def lookup(self, name):
"""Look up a phone number by name."""
return self._entries.get(name)
def remove(self, name):
"""Remove an entry."""
self._entries.pop(name, None)
def list_all(self):
"""Return all entries as a list of tuples."""
return list(self._entries.items())
Wrapping up
We've covered:
- Creating dictionaries -
{}ordict() - Accessing values -
d[key]ord.get(key, default) - Dictionary methods -
keys(),values(),items(),pop(),update() - Iteration - Over keys, values, or items
- Dictionary comprehensions -
{k: v for k, v in items} - Merging -
{**d1, **d2}ord1 | d2 - defaultdict - Auto-initialize missing keys
- Counter - Specialized counting dictionary
- Dispatch tables - Replace long if/elif chains
TDD with dictionaries
When testing dictionary-based code:
- Test basic add/get operations
- Test handling of missing keys
- Test edge cases (empty dict, duplicate keys)
- Use
dict()to compareCounterordefaultdictto regular dicts
Dictionaries are one of Python's most powerful and commonly used data structures. Master them and you'll be able to solve many problems elegantly!