Python Dictionaries

A Python dictionary is a collection of key-value pairs where each unique key maps to a value. Unlike lists, which use numeric positions to find items, dictionaries let you look up data by a meaningful label — a name, an ID, a word — making them the go-to tool whenever you need fast, labeled access to data. They are one of the most heavily used data structures in Python, powering everything from configuration objects to JSON data to the internal implementation of Python classes themselves.

Overview / How Dictionaries Work

A dictionary is written with curly braces {} containing key: value pairs separated by commas. Keys must be hashable — meaning Python can compute a fixed numeric fingerprint for them that never changes during their lifetime. Immutable types like str, int, float, and tuple (if it only contains hashable items) work as keys; mutable types like list, dict, and set do not, because their contents — and therefore their hash — could change after being used as a key. Values, on the other hand, can be absolutely anything: numbers, strings, lists, other dictionaries, even functions.

Internally, a dictionary is implemented as a hash table. When you write d["name"], Python does not scan every entry looking for a match the way it would with a list. Instead, it runs the key through a hash function to get a number, uses that number to jump almost directly to the memory slot where the value lives, and then confirms the key really matches (to handle rare hash collisions). This is why dictionary lookups, insertions, and deletions run in average O(1) time — effectively constant time regardless of how many items are stored — while searching for a value inside a list takes O(n) time because every element might need to be checked.

Since Python 3.7, dictionaries also guarantee that they preserve insertion order: iterating over a dictionary yields keys in the exact order they were first added, even though the underlying hash table is not literally ordered by key. This was an implementation detail in CPython 3.6 that became an official language guarantee in 3.7.

Syntax

my_dict = {
    "key1": "value1",
    "key2": "value2",
}
print(my_dict)

Output:

{'key1': 'value1', 'key2': 'value2'}
Part Meaning
{} Creates an empty dictionary (or wraps key-value pairs)
key: value One entry; the key must be hashable, the value can be any type
, Separates multiple key-value pairs (a trailing comma is allowed)
dict() Alternative constructor, e.g. dict(a=1, b=2) or dict([("a", 1)])

Examples

Example 1: Creating, Reading, and Updating

student = {
    "name": "Ava",
    "age": 21,
    "major": "Computer Science"
}

print(student["name"])
print(student.get("gpa", "Not recorded"))

student["age"] = 22
student["gpa"] = 3.8

print(student)

Output:

Ava
Not recorded
{'name': 'Ava', 'age': 22, 'major': 'Computer Science', 'gpa': 3.8}

Square-bracket access (student["name"]) reads an existing key directly. .get() is a safer read that returns a default ("Not recorded" here) instead of crashing when the key is missing. Assigning to a bracketed key that already exists updates its value in place; assigning to a new key ("gpa") inserts it at the end of the dictionary’s iteration order.

Example 2: Iterating and Common Methods

prices = {"apple": 0.5, "banana": 0.25, "cherry": 3.0}

for fruit, price in prices.items():
    print(f"{fruit}: ${price:.2f}")

print("apple" in prices)
print("mango" in prices)

removed = prices.pop("banana")
print(f"Removed banana, which cost ${removed:.2f}")
print(list(prices.keys()))
print(list(prices.values()))

Output:

apple: $0.50
banana: $0.25
cherry: $3.00
True
False
Removed banana, which cost $0.25
['apple', 'cherry']
[0.5, 3.0]

.items() yields (key, value) tuples, ideal for unpacking in a for loop. The in operator checks membership among the keys (also an O(1) hash lookup, unlike checking membership in a list). .pop(key) removes an entry and returns its value; calling it on a missing key without a default would raise a KeyError. .keys() and .values() return view objects, which is why they’re wrapped in list() here to print them as lists.

Example 3: A Realistic Word Counter and Dict Comprehension

text = "the quick brown fox jumps over the lazy dog the fox runs"
word_counts = {}

for word in text.split():
    word_counts[word] = word_counts.get(word, 0) + 1

print(word_counts)

sorted_words = sorted(word_counts.items(), key=lambda pair: pair[1], reverse=True)
for word, count in sorted_words[:3]:
    print(f"{word!r} appeared {count} times")

squares = {n: n ** 2 for n in range(1, 6)}
print(squares)

Output:

{'the': 3, 'quick': 1, 'brown': 1, 'fox': 2, 'jumps': 1, 'over': 1, 'lazy': 1, 'dog': 1, 'runs': 1}
'the' appeared 3 times
'fox' appeared 2 times
'quick' appeared 1 times
{1: 1, 2: 4, 3: 9, 4: 16, 5: 25}

This is the classic “counting with a dictionary” pattern: word_counts.get(word, 0) + 1 returns the running count (or 0 for a brand-new word) and adds one, avoiding a manual if word not in word_counts check. Sorting .items() by the count with a lambda key lets you rank entries. The final line is a dict comprehension{key_expr: value_expr for item in iterable} — a compact way to build a dictionary from an iterable, directly analogous to a list comprehension.

Under the Hood

When Python evaluates d[key], three things happen: (1) it calls hash(key) to get an integer; (2) it uses that integer (modulo the table’s internal size) to locate a slot in the underlying array; (3) it compares the stored key to your key with == to make sure it found the right one, since two different keys can occasionally hash to the same slot (a collision), which CPython resolves by probing nearby slots. This is why dictionary keys must be both hashable and support equality comparison consistently.

As you add more entries, CPython automatically resizes the underlying hash table (roughly doubling it) once it gets too full, rehashing all existing keys into the new, larger table. This resizing is amortized across insertions, which is why insertion stays O(1) on average even as the dictionary grows. Because keys are hashed once and never re-scanned linearly, dictionary performance barely changes whether you have 10 entries or 10 million.

It’s also worth knowing that dictionaries are mutable objects passed by reference. Assigning d2 = d1 does not copy the dictionary — both names point to the same object in memory, so mutating one through either name affects both. Use d1.copy() (a shallow copy) or copy.deepcopy(d1) (for nested structures) when you need an independent dictionary.

Common Mistakes

Mistake 1: Assuming a Key Exists

Accessing a missing key with square brackets raises a KeyError and crashes the program if not handled:

inventory = {"apples": 10, "bananas": 5}
print(inventory["oranges"])  # KeyError: 'oranges'

Python raises KeyError: 'oranges' here because "oranges" was never inserted into the dictionary. The fix is to use .get() with a default, or check membership with in before accessing:

inventory = {"apples": 10, "bananas": 5}

print(inventory.get("oranges", 0))
if "oranges" in inventory:
    print(inventory["oranges"])
else:
    print("No oranges in inventory")

Output:

0
No oranges in inventory

Mistake 2: Using a Mutable Default Argument

Default argument values in Python are evaluated once, when the function is defined — not each time it’s called. Using a dictionary (or list) as a default causes it to be silently shared and mutated across every call:

def add_to_cart(item, cart={}):
    cart[item] = cart.get(item, 0) + 1
    return cart

cart1 = add_to_cart("apple")
cart2 = add_to_cart("banana")
print(cart1)
print(cart2)

Output:

{'apple': 1, 'banana': 1}
{'apple': 1, 'banana': 1}

Both variables end up pointing at the same dictionary, so cart1 unexpectedly contains "banana" too. The standard fix is to default to None and create a fresh dictionary inside the function body:

def add_to_cart(item, cart=None):
    if cart is None:
        cart = {}
    cart[item] = cart.get(item, 0) + 1
    return cart

cart1 = add_to_cart("apple")
cart2 = add_to_cart("banana")
print(cart1)
print(cart2)

Output:

{'apple': 1}
{'banana': 1}

Best Practices

  • Use .get(key, default) instead of d[key] whenever a key might legitimately be missing, rather than wrapping access in a try/except KeyError.
  • Use d.setdefault(key, default) when you need to both read and initialize a missing key in one step, e.g. building groups: groups.setdefault(category, []).append(item).
  • Prefer a dict comprehension ({k: v for k, v in ...}) over building a dictionary with a manual loop when the transformation is simple — it’s more readable and often faster.
  • Iterate with .items() when you need both key and value; iterating over the dictionary directly (for k in d) only gives you keys.
  • Never use a mutable object (list, dict, set) as a default argument; use None and initialize inside the function.
  • Use the merge operators | and |= (Python 3.9+) to combine dictionaries: merged = d1 | d2 creates a new dict, with d2‘s values winning on key conflicts.
  • Add type hints like dict[str, int] (Python 3.9+) to document what a dictionary is expected to contain.
  • Use collections.Counter for pure counting tasks and collections.defaultdict when every key should start with a default value — both are dictionary subclasses built for these exact patterns.

Practice Exercises

  • Exercise 1: Write a function invert_dict(d) that takes a dictionary and returns a new dictionary with keys and values swapped. Test it with {"a": 1, "b": 2, "c": 3} and confirm the result is {1: 'a', 2: 'b', 3: 'c'}.
  • Exercise 2: Given a list of student score dictionaries like [{"name": "Sam", "score": 85}, {"name": "Lee", "score": 92}], write code that builds a single dictionary mapping each name to their score, then prints the name of the student with the highest score.
  • Exercise 3: Use a dictionary comprehension to build a dictionary mapping each letter in the string "mississippi" to how many times it appears. What should the resulting dictionary look like?

Summary

  • A dictionary stores key: value pairs and is written with curly braces; keys must be hashable (immutable), values can be anything.
  • Dictionaries are hash tables under the hood, giving average O(1) lookup, insertion, and deletion regardless of size.
  • Since Python 3.7, dictionaries preserve insertion order when iterated.
  • Use .get(), in, .items(), .keys(), .values(), .pop(), and .setdefault() as the core toolkit for safe, idiomatic dictionary work.
  • Dict comprehensions ({k: v for ...}) build dictionaries concisely from iterables.
  • Watch out for accessing missing keys directly and for using mutable objects as default function arguments — both are classic sources of bugs.