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 --> LookSparse

A brief explanation of the modern storage structure:

  1. Sparse Indices Array: A small array of integers acting as index pointers (e.g. [-1, 0, -1, 1, -1]).
  2. 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 #

OperationComplexityNotes
d[key]O(1) avgworst case O(n) — hash collision
d[key] = valO(1) avg
del d[key]O(1) avg
key in dO(1) avgfar 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 dO(n)iterate all keys
d.copy()O(n)shallow copy
d | d2O(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 the if 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.
  • defaultdict eliminates the if key not in d: d[key] = [] pattern — just use the key directly.
  • Counter for counting frequencies, supports arithmetic between counters and .most_common(n).
  • TypedDict for dicts with a fixed structure that need type-checker validation.
  • key in dict is O(1) — far faster than key in list which 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 a deep_get helper function.

← Previous: Lists   Next: Date & Time →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact