Dictionaries #
A dictionary is Python’s core data structure for storing key-value pairs with O(1) access by key. Since Python 3.7, dictionaries guarantee insertion order — meaning when you iterate, elements come out in the same order they were inserted. Dictionaries are extremely versatile: used for configuration, caching, data grouping, counting, and as a lightweight alternative to classes for structured data. Knowing the idioms and the right dictionary variants — defaultdict, Counter, TypedDict — makes your code far more expressive and boilerplate-free.
Creating Dictionaries #
# Ways to create dictionaries
empty = {}
empty2 = dict()
# Literal — the most common way
user = {
"name": "Budi Santoso",
"age": 28,
"email": "[email protected]",
"active": True,
}
# From keyword arguments — only for keys that are valid Python identifiers
config = dict(host="localhost", port=5432, ssl=False)
# From a list of tuples
from_tuple = dict([("a", 1), ("b", 2), ("c", 3)])
# Dict comprehension
squares = {x: x**2 for x in range(1, 6)}
print(squares) # → {1: 1, 2: 4, 3: 9, 4: 16, 5: 25}
# Dict from two lists using zip
keys = ["name", "age", "city"]
values = ["Budi", 28, "Jakarta"]
profile = dict(zip(keys, values))
print(profile) # → {'name': 'Budi', 'age': 28, 'city': 'Jakarta'}
# Initialize with a default value for all keys
initial_scores = dict.fromkeys(["math", "physics", "chemistry"], 0)
print(initial_scores) # → {'math': 0, 'physics': 0, 'chemistry': 0}
Accessing Values #
The way you access values is the most important distinction to understand — there are two ways, and each fits a different situation:
data = {"name": "Budi", "age": 28, "city": "Jakarta"}
# Access via [] — raises KeyError if the key is missing
print(data["name"]) # → Budi
# print(data["email"]) # → KeyError: 'email'
# Access via get() — returns None (or a default) if the key is missing
print(data.get("email")) # → None (no error)
print(data.get("email", "empty")) # → empty (explicit default value)
print(data.get("name", "anonymous"))# → Budi (key exists, default ignored)
# ANTI-PATTERN: direct access for keys that might not exist
def show_profile(user: dict) -> None:
print(user["name"]) # crashes if "name" is missing
print(user["phone"]) # almost certainly crashes
# CORRECT: use get() with a sensible default
def show_profile(user: dict) -> None:
print(user.get("name", "User"))
print(user.get("phone", "Not available"))
# ANTI-PATTERN: a verbose check-then-access pattern
if "email" in user:
email = user["email"]
else:
email = "[email protected]"
# CORRECT: get() is more concise
email = user.get("email", "[email protected]")
How Key Lookup Works Under the Hood #
Behind the scenes, a Python dictionary is implemented using a Hash Table. Since Python 3.6+ (officially the standard in 3.7+), the hash table structure is optimized to save memory and preserve insertion order (insertion-ordered). The structure separates the table into a sparse indices array and a dense entries array.
Here’s the key lookup flow diagram and how it relates to modern Python’s memory structure:
flowchart TD
Start["Start Key Lookup: key"] --> Hash["Compute Hash Value: hash(key)"]
Hash --> Mask["Map to Index: hash & mask"]
Mask --> LookSparse["Check the Slot in the Sparse Indices Array"]
LookSparse -->|Empty Slot -1| NotFound["Key Not Found (KeyError / Default)"]
LookSparse -->|Slot Holds idx| GetEntry["Get the idx-th Entry in the Dense Entries Array"]
GetEntry --> Compare{"Is entries[idx].key == key?"}
Compare -->|Yes| Found["Key Matches: Return entries[idx].value"]
Compare -->|"No (Hash Collision)"| Probe["Compute a New Index (Probing / Open Addressing)"]
Probe --> LookSparseA brief explanation of the modern storage structure:
- Sparse Indices Array: A small array of integers acting as index pointers (e.g.
[-1, 0, -1, 1, -1]). - Dense Entries Array: A compact data array storing the actual key-value pairs in insertion order:
entries[0] = (hash_code, key1, value1)entries[1] = (hash_code, key2, value2)
This separation lets Python 3.7+ dictionaries use up to 30% less memory than earlier versions, while keeping the average access time at \(O(1)\).
Modifying Dictionaries #
data = {"a": 1, "b": 2}
# Add or update keys
data["c"] = 3 # add a new key
data["a"] = 10 # update an existing key's value
print(data) # → {'a': 10, 'b': 2, 'c': 3}
# setdefault() — add a key ONLY if it doesn't exist
data.setdefault("d", 0) # adds 'd': 0 because it doesn't exist
data.setdefault("a", 99) # 'a' already exists, not changed
print(data) # → {'a': 10, 'b': 2, 'c': 3, 'd': 0}
# update() — update from another dict or keyword args
data.update({"e": 5, "f": 6})
data.update(g=7, h=8)
print(data) # → {'a': 10, 'b': 2, 'c': 3, 'd': 0, 'e': 5, 'f': 6, 'g': 7, 'h': 8}
# Removing elements
d = {"a": 1, "b": 2, "c": 3, "d": 4}
value_b = d.pop("b") # remove and return the value: O(1)
print(value_b, d) # → 2 {'a': 1, 'c': 3, 'd': 4}
value_x = d.pop("x", None) # pop with a default — no error if missing
print(value_x) # → None
last_item = d.popitem() # remove and return the last pair (LIFO)
print(last_item) # → ('d', 4)
del d["a"] # remove a specific key without returning the value
d.clear() # remove all elements
Iterating Dictionaries #
menu = {"fried rice": 25000, "chicken noodles": 20000, "iced tea": 5000}
# Iterate keys (default)
for key in menu:
print(key)
# Iterate keys explicitly — same result
for key in menu.keys():
print(key)
# Iterate values
for price in menu.values():
print(f"Rp{price:,}")
# Iterate key-value pairs — the most common usage
for item_name, price in menu.items():
print(f"{item_name}: Rp{price:,}")
# → fried rice: Rp25,000
# → chicken noodles: Rp20,000
# → iced tea: Rp5,000
# ANTI-PATTERN: accessing values by key while iterating
for key in menu:
print(menu[key]) # unnecessary — there's a better way
# CORRECT: use .values() or .items()
for price in menu.values():
print(price)
for name, price in menu.items():
print(name, price)
Dict Comprehensions #
Dict comprehensions allow concise creation or transformation of dictionaries:
# Value transformation
prices = {"apple": 5000, "orange": 8000, "mango": 12000}
# Apply a 10% discount
discounted = {name: int(p * 0.9) for name, p in prices.items()}
print(discounted)
# → {'apple': 4500, 'orange': 7200, 'mango': 10800}
# Filter by a condition
expensive = {name: p for name, p in prices.items() if p >= 8000}
print(expensive) # → {'orange': 8000, 'mango': 12000}
# Swap keys and values (invert a dict)
country_codes = {"Indonesia": "ID", "Malaysia": "MY", "Singapore": "SG"}
code_to_country = {v: k for k, v in country_codes.items()}
print(code_to_country)
# → {'ID': 'Indonesia', 'MY': 'Malaysia', 'SG': 'Singapore'}
# Build a lookup from a list of objects
product_list = [
{"id": 1, "name": "Laptop"},
{"id": 2, "name": "Mouse"},
{"id": 3, "name": "Keyboard"},
]
# Build a dict for O(1) lookup by id
product_lookup = {p["id"]: p for p in product_list}
print(product_lookup[2]) # → {'id': 2, 'name': 'Mouse'}
Merging Dictionaries #
d1 = {"a": 1, "b": 2}
d2 = {"b": 20, "c": 3} # 'b' is in both — d2 wins
# update() — modifies d1 in place (all Python versions)
d1_copy = d1.copy()
d1_copy.update(d2)
print(d1_copy) # → {'a': 1, 'b': 20, 'c': 3}
# {**d1, **d2} — creates a new dict (Python 3.5+)
merged = {**d1, **d2}
print(merged) # → {'a': 1, 'b': 20, 'c': 3}
# The | operator — creates a new dict (Python 3.9+) — most concise
merged = d1 | d2
print(merged) # → {'a': 1, 'b': 20, 'c': 3}
# The |= operator — in-place update (Python 3.9+)
d1 |= d2
print(d1) # → {'a': 1, 'b': 20, 'c': 3}
# Merge many dicts with a priority order
defaults = {"debug": False, "timeout": 30, "max_retry": 3}
env = {"timeout": 60}
user = {"debug": True}
# user beats env, env beats defaults
config = defaults | env | user
print(config)
# → {'debug': True, 'timeout': 60, 'max_retry': 3}
Dictionary Variants from collections
#
defaultdict — Automatic Default Values
#
defaultdict eliminates the need to check “does the key already exist” before using it:
from collections import defaultdict
# ANTI-PATTERN: a verbose manual pattern
groups = {}
data = [("A", 1), ("B", 2), ("A", 3), ("C", 4), ("B", 5)]
for key, value in data:
if key not in groups:
groups[key] = [] # manual initialization
groups[key].append(value)
# CORRECT: defaultdict initializes automatically
groups = defaultdict(list) # default factory: list()
for key, value in data:
groups[key].append(value) # append directly — no check needed
print(dict(groups)) # → {'A': [1, 3], 'B': [2, 5], 'C': [4]}
# defaultdict with various factories
counts = defaultdict(int) # default: 0
totals = defaultdict(float) # default: 0.0
nested = defaultdict(dict) # default: {}
unique_sets = defaultdict(set) # default: set()
# Example: count character frequency
text = "mississippi"
freq = defaultdict(int)
for letter in text:
freq[letter] += 1
print(dict(freq))
# → {'m': 1, 'i': 4, 's': 4, 'p': 2}
# Example: group students by grade
students = [("Budi", "A"), ("Ani", "B"), ("Citra", "A"), ("Dedi", "B"), ("Eko", "C")]
by_grade = defaultdict(list)
for name, grade in students:
by_grade[grade].append(name)
print(dict(by_grade))
# → {'A': ['Budi', 'Citra'], 'B': ['Ani', 'Dedi'], 'C': ['Eko']}
Counter — Counting Frequencies
#
Counter is a dict subclass optimized for counting element occurrences:
from collections import Counter
# Count from an iterable
words = ["apple", "orange", "apple", "mango", "orange", "apple"]
counts = Counter(words)
print(counts)
# → Counter({'apple': 3, 'orange': 2, 'mango': 1})
# Count characters in a string
letters = Counter("abracadabra")
print(letters)
# → Counter({'a': 5, 'b': 2, 'r': 2, 'c': 1, 'd': 1})
# The N most frequent elements
print(letters.most_common(3))
# → [('a', 5), ('b', 2), ('r', 2)]
# Arithmetic between Counters
c1 = Counter({"apple": 3, "orange": 2})
c2 = Counter({"apple": 1, "mango": 4})
print(c1 + c2) # → Counter({'mango': 4, 'apple': 4, 'orange': 2})
print(c1 - c2) # → Counter({'orange': 2, 'apple': 2})
print(c1 & c2) # → Counter({'apple': 1}) (minimum)
print(c1 | c2) # → Counter({'mango': 4, 'apple': 3, 'orange': 2}) (maximum)
# Update a counter
counts.update(["apple", "mango"])
counts["orange"] += 5
ChainMap — Multiple Dicts as One View
#
ChainMap combines several dictionaries into a single view without copying data:
from collections import ChainMap
default_config = {"debug": False, "timeout": 30, "theme": "light"}
user_config = {"debug": True, "theme": "dark"}
env_config = {"timeout": 60}
# Lookup order: env_config → user_config → default_config
config = ChainMap(env_config, user_config, default_config)
print(config["debug"]) # → True (from user_config)
print(config["timeout"]) # → 60 (from env_config)
print(config["theme"]) # → dark (from user_config)
# Modifications only affect the first map
config["new"] = "value"
print(env_config) # → {'timeout': 60, 'new': 'value'}
TypedDict — Dictionaries with Type Safety
#
TypedDict (Python 3.8+) lets you define an exact type for each key, so a type checker can validate usage:
from typing import TypedDict, Optional
class User(TypedDict):
name: str
email: str
age: int
phone: Optional[str] # may be None
class PartialUser(TypedDict, total=False):
# total=False: all keys become optional
name: str
email: str
# A type checker validates this
user: User = {
"name": "Budi",
"email": "[email protected]",
"age": 28,
"phone": None,
}
# mypy will flag these as errors:
# user["salary"] = 5000 # Extra key 'salary' not allowed
# user["age"] = "28" # 'str' incompatible with 'int'
Idiomatic Patterns #
Grouping Data #
# Group transactions by category
transactions = [
{"category": "food", "amount": 50000},
{"category": "transport", "amount": 30000},
{"category": "food", "amount": 75000},
{"category": "entertainment", "amount": 100000},
{"category": "transport", "amount": 25000},
]
from collections import defaultdict
by_category = defaultdict(list)
for t in transactions:
by_category[t["category"]].append(t["amount"])
total_by_category = {k: sum(v) for k, v in by_category.items()}
print(total_by_category)
# → {'food': 125000, 'transport': 55000, 'entertainment': 100000}
Caching Computation Results (Manual Memoization) #
# Store expensive computation results so they're not recomputed
_fibonacci_cache: dict[int, int] = {}
def fibonacci(n: int) -> int:
if n in _fibonacci_cache:
return _fibonacci_cache[n]
if n <= 1:
return n
result = fibonacci(n - 1) + fibonacci(n - 2)
_fibonacci_cache[n] = result
return result
# Or use functools.lru_cache (more recommended)
from functools import lru_cache
@lru_cache(maxsize=None)
def fibonacci_lru(n: int) -> int:
if n <= 1:
return n
return fibonacci_lru(n - 1) + fibonacci_lru(n - 2)
Nested Dictionaries #
# Safe access to nested dicts
config = {
"database": {
"host": "localhost",
"port": 5432,
}
}
# ANTI-PATTERN: direct access — crashes if a key is missing anywhere
host = config["database"]["host"] # safe only if you know the exact structure
port = config["server"]["port"] # KeyError: 'server'
# CORRECT: chained get()
host = config.get("database", {}).get("host", "localhost")
port = config.get("server", {}).get("port", 8080)
# For deeper nesting, consider a helper function
def deep_get(d: dict, *keys, default=None):
"""Access a nested dict safely."""
for k in keys:
if not isinstance(d, dict):
return default
d = d.get(k, {})
return d if d != {} else default
print(deep_get(config, "database", "host")) # → localhost
print(deep_get(config, "server", "port", default=8080)) # → 8080
Time Complexity of Dictionary Operations #
| Operation | Complexity | Notes |
|---|---|---|
d[key] | O(1) avg | worst case O(n) — hash collision |
d[key] = val | O(1) avg | |
del d[key] | O(1) avg | |
key in d | O(1) avg | far faster than a list! |
d.get(key) | O(1) avg | |
d.keys() | O(1) | returns a view, not a list |
d.values() | O(1) | returns a view, not a list |
d.items() | O(1) | returns a view, not a list |
for k in d | O(n) | iterate all keys |
d.copy() | O(n) | shallow copy |
d | d2 | O(n+m) | creates a new dict |
The O(1) key operations are the main advantage of dicts over lists. Use a dict when you need fast lookups by an identifier — for example caches, indexes, or configuration.
Summary #
- Use
.get(key, default)for keys that might not exist — safer and more concise than theif key in d: ... else: ...pattern.dict.fromkeys(keys, default)to initialize a dict with a uniform default value for all keys.setdefault(key, default)adds a key only if it doesn’t exist — useful for lazy initialization.- The
|operator (Python 3.9+) is the most concise way to merge two dicts. Use{**d1, **d2}for Python 3.5+ compatibility.defaultdicteliminates theif key not in d: d[key] = []pattern — just use the key directly.Counterfor counting frequencies, supports arithmetic between counters and.most_common(n).TypedDictfor dicts with a fixed structure that need type-checker validation.key in dictis O(1) — far faster thankey in listwhich is O(n). Use dicts/sets for repeated lookups.- Dict comprehensions for dict transformation and filtering — more expressive than manual loops.
- Don’t access nested dicts with chained
[]without structure guarantees — use chained.get()or adeep_gethelper function.