Skip to main content

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
  • defaultdict and Counter from 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 - {} or dict()
  • Accessing values - d[key] or d.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} or d1 | 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:

  1. Test basic add/get operations
  2. Test handling of missing keys
  3. Test edge cases (empty dict, duplicate keys)
  4. Use dict() to compare Counter or defaultdict to 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!