Python Dictionaries
Key-Value Storage Done Right
Master Python dictionaries β from creation and access to 15+ methods, comprehensions, nested structures, and performance. Over 50 runnable examples, each explained in depth: why it's used, how it operates, how it affects your program, how it shapes the outcome, and why it matters.
prices = {"apple": 0.5, "bread": 2.3, "milk": 1.8}
prices["eggs"] = 3.0
for item, price in prices.items():
print(f"{item}: ${price:.2f}")
apple: $0.50
bread: $2.30
milk: $1.80
eggs: $3.00
π What Is a Python Dictionary?
A dictionary is Python's built-in key-value store β an unordered (well, technically insertion-ordered since 3.7) collection where every value is looked up not by position, like a list, but by a unique, meaningful key. If a list answers "what's in slot 3?", a dictionary answers "what's the value for 'username'?" β a question your programs ask constantly.
Why Every Programmer Must Learn This Curriculum
Dictionaries are Python's implementation of a hash map β one of the most important data structures in all of computer science. Nearly every configuration file, API response, database record, and cache your programs ever touch is represented as key-value pairs. If lists taught you to think in sequences, dictionaries teach you to think in structured, labelled data β the shape that real-world information actually takes.
This curriculum is sequenced from first principles to production patterns. You'll start with how dictionaries are created and accessed, move through every essential method, master dictionary comprehensions, handle nested dictionaries (the backbone of JSON), and finish with the performance guarantees β O(1) average-case lookup β that make dictionaries indispensable at scale.
By the end, you'll understand not just the syntax but the why: why dictionary keys must be hashable, why dict.get() is almost always safer than bracket access, why merging dictionaries changed dramatically in Python 3.9, and why a dictionary is frequently the correct answer to "how do I make this fast?"
π― What You Will Learn
- How dictionaries are created, and how Python stores key-value pairs internally
- Accessing, adding, and updating values β bracket notation vs the safer
.get() - The three "view" objects:
.keys(),.values(), and.items() - 15+ built-in dictionary methods: retrieving, updating, removing, and copying
- Dictionary comprehensions β the concise, Pythonic way to build new dictionaries
- Nested dictionaries β modelling JSON, configuration, and structured records
- Merging dictionaries three different ways, including the modern
|operator collections.defaultdictandCounterβ specialised dictionary subclasses- Performance: why dictionary lookup is O(1) and why that matters
- Three real-world mini projects that combine everything above
π The Significance of Dictionaries in the Programming World
Dictionaries are how programs model meaning, not just position. A list tells you "the third thing." A dictionary tells you "the thing called email." That distinction β labelled, structured access β is how almost all real-world data is naturally shaped:
- Web development β JSON, the universal format for APIs, maps directly onto nested Python dictionaries the instant it's parsed.
- Configuration & settings β application config, environment variables, and feature flags are almost always key-value.
- Databases β a row fetched from SQL or MongoDB is typically handed back to your code as a dictionary.
- Caching & memoisation β dictionaries are the underlying mechanism behind
functools.lru_cacheand virtually every caching layer in software. - Counting & grouping β word frequency, vote tallying, grouping records by category β dictionaries are the natural tool.
- Computer science fundamentals β the hash table is one of the most studied data structures in CS education, and Python's dictionary is a highly-optimised, real-world implementation you can learn directly from.
Mastering dictionaries means you can model almost any real-world entity β a user profile, a product listing, a game character's stats β directly and naturally in code, without contorting the data into an awkward list-of-positions structure.
π€ Why Programmers Should Master Python Dictionaries
Because most real data has a name, not just a position
π§© Dictionaries Model Reality More Naturally Than Lists
Think about a user profile: name, email, age, city. Storing this as a list β ["Glenn", "glenn@email.com", 30, "Diplahan"] β forces you to memorise that index 0 is the name and index 2 is the age. A dictionary removes that burden entirely: user["age"] is self-documenting and safe even if the fields are reordered.
- Represent records β a person, a product, a transaction β with named fields.
- Fast lookup β find a value by key in constant time, regardless of how large the dictionary grows.
- Count & group β tally occurrences, bucket items by category.
- Cache results β remember expensive computations keyed by their input.
- Parse structured data β JSON, YAML, and config files map naturally onto nested dictionaries.
names = [...] and ages = [...], matched only by shared index position β that's almost always a sign you actually want a list of dictionaries or a single dictionary keyed by name.π Dictionaries Are the Engine Behind Fast Python
Whenever you hear "just use a dictionary for O(1) lookup," this is why: dictionaries use a hash table internally, meaning that no matter how many keys they hold, finding a value by key takes roughly constant time. This single property turns slow, nested-loop algorithms into fast, single-pass ones β a transformation you'll practice directly in the Advanced section.
π€ Dictionary Basics β Creation, Access, and Views
The foundation everything else in this tutorial builds on
Literals, the dict() Constructor, zip(), and fromkeys()
A dictionary literal uses curly braces with key: value pairs. Several other constructors build dictionaries from existing data.
# Example 1: an empty dictionary empty = {} print(empty, type(empty)) # Example 2: a dictionary literal person = {"name": "Glenn", "age": 30, "city": "Diplahan"} print(person) # Example 3: dict() constructor with keyword arguments config = dict(host="localhost", port=8080, debug=True) print(config) # Example 4: dict() constructor from a list of (key, value) tuples pairs = [("a", 1), ("b", 2), ("c", 3)] print(dict(pairs)) # Example 5: dict() constructor from zip() of two parallel lists keys = ["apple", "banana", "cherry"] values = [0.5, 0.3, 2.0] prices = dict(zip(keys, values)) print(prices) # Example 6: fromkeys() β same default value for many keys inventory = dict.fromkeys(["sword", "shield", "potion"], 0) print(inventory)
βοΈ How it operates: A literal directly maps each written key to its value.
dict(**kwargs) treats keyword arguments as key-value pairs. dict(pairs) and dict(zip(keys, values)) both consume an iterable of two-item pairs and build a dictionary from them. dict.fromkeys(iterable, value) creates one entry per item in iterable, all sharing the exact same default value.
π How it affects the program: Example 5's
zip() pattern is the direct dictionary equivalent of the list zip() pattern you may already know β it's the standard way to combine two "parallel" lists (like column headers and a row of values) into a single structured mapping.
π― How it stimulates the outcome:
fromkeys() (Example 6) is the fast, correct way to initialise a dictionary with known keys and a uniform starting value β like zeroing out an inventory count before adding real data β without writing a manual loop.
π Significance: Knowing all four construction paths means you can always build a dictionary directly from whatever shape your data currently has, instead of first reshaping it into a literal by hand.
Bracket Access, Adding Keys, and Membership Testing
person = {"name": "Glenn", "age": 30}
# Example 7: reading a value by key
print(person["name"])
# Example 8: KeyError when the key doesn't exist
try:
print(person["email"])
except KeyError as e:
print(f"Error: missing key {e}")
# Example 9: adding a NEW key just by assigning to it
person["email"] = "glenn@example.com"
print(person)
# Example 10: updating an EXISTING key's value
person["age"] = 31
print(person)
# Example 11: membership testing checks KEYS, not values
print("name" in person)
print("Glenn" in person)βοΈ How it operates:
person["name"] looks up the value associated with that exact key. If the key is missing, Python raises KeyError immediately rather than silently returning nothing. Assignment (person["email"] = ...) does double duty β if the key already exists, its value is overwritten in place; if it doesn't, a brand-new key-value pair is created. There's no separate "add" vs "update" syntax; it's the same operation either way.
π How it affects the program: Example 11 highlights a common point of confusion:
in on a dictionary checks against the keys only, not the values β "Glenn" in person is False here even though "Glenn" is a value in the dictionary, because it isn't a key.
π― How it stimulates the outcome: An uncaught
KeyError will crash your program the moment an expected field is missing β a real risk when working with external data like API responses, which is exactly why the .get() method (covered in the Methods section) exists as a safer default.
π Significance: This single "assignment adds-or-updates" behaviour is what makes dictionaries so convenient for building up structured records incrementally, one field at a time, as data becomes available.
.keys(), .values(), and .items()
scores = {"Ana": 95, "Bob": 88, "Cara": 76}
# Example 12: .keys() β all keys, in insertion order
print(list(scores.keys()))
# Example 13: .values() β all values, matching key order
print(list(scores.values()))
# Example 14: .items() β (key, value) tuples, ready for unpacking
for name, score in scores.items():
print(f"{name} scored {score}")
# Example 15: views are LIVE β they reflect later changes to the dict
keys_view = scores.keys()
scores["Dara"] = 100
print("keys_view updated automatically:", list(keys_view))βοΈ How it operates: All three methods return special view objects, not plain lists β they're lightweight windows onto the dictionary's current contents. Wrapping one in
list() (Examples 12β13) materialises it into an actual list when you need one. .items() is by far the most common in loops, since for name, score in scores.items() unpacks both the key and value in a single, readable line.
π How it affects the program: Because dictionaries maintain insertion order (guaranteed since Python 3.7),
.keys() and .values() always correspond position-by-position β the first key matches the first value, and so on β which is exactly why zip(d.keys(), d.values()) is equivalent to d.items().
π― How it stimulates the outcome: Example 15 demonstrates that view objects are "live" β they automatically reflect any later change to the dictionary, without needing to be recreated. This is efficient, but also means you should never modify a dictionary's keys while directly iterating over one of its views, as that raises a
RuntimeError.
π Significance:
.items() in particular is one of the most-used dictionary operations in all of Python β nearly every "loop over a dictionary and do something with each pair" task starts with it.
Dictionaries Change in Place β and Alias Just Like Lists
# Example 16: the SAME dictionary object changes across calls settings = {"volume": 50} original_id = id(settings) settings["volume"] = 75 print(settings, id(settings) == original_id) # Example 17: two names pointing to the same dictionary (aliasing) a = {"x": 1} b = a b["y"] = 2 print("a:", a) print("b:", b) # Example 18: passing a dict into a function shares the reference def apply_discount(prices): for key in prices: prices[key] = round(prices[key] * 0.9, 2) cart = {"book": 20.0, "pen": 2.0} apply_discount(cart) print("cart mutated by the function:", cart)
βοΈ How it operates:
settings["volume"] = 75 mutates the existing dictionary object in place β its id() stays the same, confirmed in Example 16. b = a does not copy the dictionary; it creates a second name pointing at the identical object, so mutating through b is visible through a too. Functions receive this same shared reference, so mutating a dictionary parameter inside a function (Example 18) mutates the caller's original dictionary.
π How it affects the program: This is functionally identical to the list aliasing behaviour covered in the Lists tutorial β the exact same reasoning, and the exact same risk of "spooky action at a distance" bugs, applies to dictionaries.
π― How it stimulates the outcome: Recognising this pattern lets you use it deliberately β Example 18's
apply_discount() function is genuinely useful precisely because it mutates the caller's dictionary directly, saving the cost of building and returning a whole new one.
π Significance: Whether mutation-in-place is a feature or a bug depends entirely on whether it's intentional β the Advanced section returns to this with dictionary-specific copying tools.
π Essential Dictionary Methods (15+)
Every operation you'll reach for again and again
get() β the Method You'll Use More Than Bracket Access
settings = {"theme": "dark", "volume": 75}
# Example 19: get() returns None (not a crash) for a missing key
print(settings.get("language"))
# Example 20: get() with a custom default value
print(settings.get("language", "en-US"))
# Example 21: get() vs bracket access β the safe pattern
def describe(config):
theme = config.get("theme", "light")
volume = config.get("volume", 50)
return f"Theme: {theme}, Volume: {volume}"
print(describe(settings))
print(describe({}))βοΈ How it operates:
.get(key) returns the value if the key exists, or None if it doesn't β no exception, no crash. .get(key, default) lets you specify exactly what should come back instead of None, which is almost always more useful in practice.
π How it affects the program: Example 21's
describe() function works correctly even when called with a completely empty dictionary β every field gracefully falls back to a sensible default instead of raising KeyError.
π― How it stimulates the outcome: Defensive use of
.get() with defaults is what allows programs to handle incomplete or evolving data gracefully β a config file missing a newly-added setting, or an API response from an older version of a service.
π Significance:
.get() is arguably the single most important dictionary method to internalise early β it prevents the most common dictionary-related crash in real Python programs.
update()
profile = {"name": "Glenn", "age": 30}
# Example 22: update() with another dictionary β merges and overwrites
profile.update({"age": 31, "city": "Diplahan"})
print(profile)
# Example 23: update() with keyword arguments
profile.update(job="Developer")
print(profile)
# Example 24: update() with a list of (key, value) pairs
profile.update([("country", "Philippines")])
print(profile)βοΈ How it operates:
update() accepts another dictionary, a list of key-value pairs, or keyword arguments, and for each one: if the key already exists, its value is overwritten; if not, a new key is added. It always mutates the dictionary it's called on, in place, and returns None.
π How it affects the program: Because later values always win, the order in which you call
update() (or the order of arguments within one call) determines the final result whenever keys overlap β this is the exact mechanism behind "layered defaults," where user settings should be applied after (and therefore override) application defaults.
π― How it stimulates the outcome: A single
update() call replaces what would otherwise be a manual loop of individual assignments, making bulk-modification code shorter and its intent β "merge these changes in" β immediately clear to a reader.
π Significance: This "later values win" merge behaviour reappears in the modern
| merge operator covered in the Working With Dictionaries section β understanding it here makes that syntax immediately intuitive.
pop(), popitem(), and del
cart = {"book": 20.0, "pen": 2.0, "eraser": 1.0}
# Example 25: pop() removes a key and RETURNS its value
removed_price = cart.pop("pen")
print("removed:", removed_price, "| remaining:", cart)
# Example 26: pop() with a default avoids KeyError on a missing key
missing = cart.pop("stapler", "not found")
print(missing)
# Example 27: popitem() removes the LAST inserted (key, value) pair
last_pair = cart.popitem()
print("popped pair:", last_pair, "| remaining:", cart)
# Example 28: del removes by key without returning anything
inventory = {"sword": 1, "shield": 2}
del inventory["shield"]
print(inventory)βοΈ How it operates:
pop(key) removes the given key and returns its value, raising KeyError if it doesn't exist β unless you supply a default, as in Example 26, mirroring .get()'s safety pattern. popitem() removes and returns the most recently inserted key-value pair as a tuple β useful for stack-like "undo" processing. del dict[key] deletes by key but returns nothing, making it the right choice when you don't need the removed value.
π How it affects the program: Because
popitem() always removes the last-inserted pair (in Python 3.7+, where dictionaries are insertion-ordered), it gives dictionaries a genuine Last-In-First-Out capability, similar to a list used as a stack.
π― How it stimulates the outcome: Using
pop(key, default) instead of a manual if key in d: ... del d[key] check condenses "remove this if present, and tell me what it was (or wasn't)" into a single safe expression.
π Significance: Together these three tools cover every removal scenario β with or without needing the removed value, safe or unsafe against missing keys, and by explicit key or by insertion order.
Get-or-Insert in a Single Call
votes = {"Alice": 5}
# Example 29: setdefault() returns the existing value if the key is present
print(votes.setdefault("Alice", 0))
print(votes) # unchanged β Alice already existed
# Example 30: setdefault() INSERTS the default if the key is missing
print(votes.setdefault("Bob", 0))
print(votes) # Bob added with value 0βοΈ How it operates:
setdefault(key, default) checks if key exists. If it does, it returns the existing value unchanged (Example 29). If it doesn't, it inserts key with the given default value and returns that same default (Example 30) β doing both the check and the insert in one atomic call.
π How it affects the program: Compare this to the two-step alternative:
if key not in d: d[key] = default followed by d[key] β setdefault() replaces both lines with one, and is the classic idiom for building up dictionaries of lists (e.g. grouping items by category) without a separate existence check every time.
π― How it stimulates the outcome: This pattern appears constantly when the default value is itself mutable β for example,
groups.setdefault(category, []).append(item) initialises an empty list only the first time a category is seen, then appends to it every time after, whether it just got created or already existed.
π Significance:
setdefault() is the manual, general-purpose version of the specialised collections.defaultdict class covered later β understanding it here makes defaultdict's behaviour immediately clear.
copy(), clear(), and the Shallow-Copy Trap
import copy # Example 31: .copy() makes an independent top-level copy original = {"a": 1, "b": 2} shallow = original.copy() shallow["c"] = 3 print("original:", original) print("shallow:", shallow) # Example 32: shallow copy still shares NESTED mutable values users = {"alice": {"role": "admin"}} users_copy = users.copy() users_copy["alice"]["role"] = "guest" # mutates the SHARED inner dict print("original affected too:", users) deep_users = copy.deepcopy(users) deep_users["alice"]["role"] = "superadmin" print("original NOT affected:", users) # Example 33: clear() empties a dictionary in place temp = {"x": 1, "y": 2} temp.clear() print(temp)
βοΈ How it operates:
.copy() creates a new top-level dictionary object with the same keys and values. If a value is itself mutable β like a nested dictionary β that nested object is shared, not duplicated, which Example 32 demonstrates directly. copy.deepcopy() recursively copies every nested level, producing full independence. .clear() removes every key-value pair from the dictionary in place.
π How it affects the program: This is the dictionary equivalent of the list shallow-copy trap covered in the previous tutorial β the same rule applies: shallow copies protect the top level only.
π― How it stimulates the outcome: When your dictionary's values are simple (strings, numbers),
.copy() is sufficient and cheap. The moment values become nested dictionaries, lists, or other mutable objects β very common with parsed JSON β reach for deepcopy() to avoid silent cross-contamination between "copies."
π Significance: This shallow/deep distinction is one of the most consistently misunderstood aspects of Python across every mutable container type, and dictionaries β being the most common way to represent nested, structured data β are where it bites hardest.
β¨ Dictionary Comprehensions & Nested Dictionaries
The concise, Pythonic way to build and reshape dictionaries
{key_expr: value_expr for item in iterable if condition}
# Example 34: the loop way (for comparison) squares_loop = {} for n in range(6): squares_loop[n] = n ** 2 print(squares_loop) # Example 35: the same result as a dict comprehension squares_comp = {n: n ** 2 for n in range(6)} print(squares_comp) # Example 36: comprehension with a filter condition even_squares = {n: n ** 2 for n in range(10) if n % 2 == 0} print(even_squares) # Example 37: building a dict from an existing list words = ["apple", "banana", "kiwi"] lengths = {word: len(word) for word in words} print(lengths) # Example 38: transforming an existing dictionary's VALUES prices = {"apple": 0.5, "bread": 2.3} with_tax = {item: round(price * 1.12, 2) for item, price in prices.items()} print(with_tax)
βοΈ How it operates:
{key_expr: value_expr for item in iterable if condition} evaluates both a key expression and a value expression for every qualifying item, collecting the results into a new dictionary. Example 38 shows the most common real-world shape: iterating over an existing dictionary's .items() to build a transformed copy β original keys kept, values recalculated.
π How it affects the program: If the same key expression is produced more than once during the comprehension, later occurrences silently overwrite earlier ones β exactly like normal dictionary assignment β so it's worth confirming your key expression is genuinely unique across the iterable.
π― How it stimulates the outcome: Comprehensions built directly from
.items() (Example 38) are the standard, idiomatic way to derive a new dictionary from an old one β applying a tax rate, converting units, or reformatting values β without mutating the original.
π Significance: Dictionary comprehensions are the direct dictionary counterpart to list comprehensions, and fluency with both marks a genuine step up in Pythonic code style.
Swapping Keys and Values, and Inverting a Mapping
# Example 39: swap keys and values (requires unique, hashable values) country_codes = {"PH": "Philippines", "US": "United States"} by_name = {name: code for code, name in country_codes.items()} print(by_name) # Example 40: conditional value inside the expression (ternary) grades = {"Ana": 95, "Bob": 62, "Cara": 78} status = {name: ("pass" if score >= 70 else "fail") for name, score in grades.items()} print(status) # Example 41: building a dict of lists β grouping with setdefault-style logic words = ["apple", "ant", "bear", "bee", "cat"] grouped = {} for word in words: grouped.setdefault(word[0], []).append(word) print(grouped)
βοΈ How it operates: Example 39 flips which side is the key and which is the value by simply swapping their positions in the comprehension β this only works safely if the original values are themselves unique and hashable, since they're about to become keys. Example 40 embeds a ternary expression as the value, similar to the list comprehension version covered previously. Example 41 uses
setdefault() inside a loop (grouping doesn't translate cleanly into a single comprehension) to bucket words by their first letter.
π How it affects the program: If
country_codes in Example 39 had two codes mapping to the same country name, the inverted dictionary would silently lose one of them β a real risk worth checking for before relying on this pattern with untrusted data.
π― How it stimulates the outcome: Example 41's grouping pattern β
d.setdefault(key, []).append(item) β is one of the single most useful one-liners in everyday Python, converting a flat list into categories in one pass.
π Significance: These patterns β inversion, conditional relabelling, and grouping β are the building blocks of most real data-wrangling tasks you'll perform with dictionaries.
Dictionaries of Dictionaries β Modelling JSON and Records
# Example 42: a nested dictionary representing multiple user records users = { "u1": {"name": "Ana", "roles": ["admin"]}, "u2": {"name": "Bob", "roles": ["editor", "viewer"]}, } print(users["u2"]["name"]) print(users["u2"]["roles"]) # Example 43: safely reading a deeply nested value with chained .get() missing_safe = users.get("u3", {}).get("name", "Unknown user") print(missing_safe) # Example 44: iterating over a nested structure for user_id, info in users.items(): roles = ", ".join(info["roles"]) print(f"{user_id}: {info['name']} ({roles})")
βοΈ How it operates: Double indexing β
users["u2"]["name"] β first selects the outer record by its ID, then the specific field within that record. Example 43 chains .get() calls with fallback defaults at each level, so a missing outer key ("u3") safely returns an empty dictionary {} instead of crashing, and the subsequent .get("name", ...) on that empty dictionary then returns the final fallback.
π How it affects the program: Directly chaining bracket access β
users["u3"]["name"] β would raise KeyError on the very first missing key; the .get().get() chain in Example 43 is the standard defensive pattern for safely navigating nested, possibly-incomplete data.
π― How it stimulates the outcome: Example 44 shows the natural iteration pattern for nested records β unpack the outer
.items(), then reach into each inner dictionary's known fields β the exact structure you'll use when rendering a user list, a product catalogue, or any record-based UI.
π Significance: Nested dictionaries are the plain-Python representation of JSON, and fluency here is a direct, practical prerequisite for working with virtually any web API.
π§© Working With Dictionaries β Patterns You'll Use Constantly
Iteration, merging, sorting, and the specialised dictionary subclasses in collections
Looping Over Keys, Values, and Sorted Order
inventory = {"sword": 5, "shield": 2, "potion": 12}
# Example 45: iterating a dict directly loops over its KEYS
for item in inventory:
print(item)
# Example 46: looping with both key and value via .items()
for item, count in inventory.items():
print(f"{item}: {count} in stock")
# Example 47: iterating in sorted key order
for item in sorted(inventory):
print(f"{item} -> {inventory[item]}")
# Example 48: iterating sorted by VALUE instead of key
for item, count in sorted(inventory.items(), key=lambda pair: pair[1]):
print(f"{item}: {count}")βοΈ How it operates: A bare
for item in dictionary loop implicitly iterates over the keys β identical to explicitly calling .keys(). sorted(dictionary) sorts those keys and returns a plain list, ready to loop over in order. Sorting by value (Example 48) reuses the same key= pattern from list sorting, applied to the (key, value) tuples produced by .items().
π How it affects the program: Sorting always creates a new, separate list or iterable β the dictionary itself is never reordered, since dictionaries don't support arbitrary reordering the way lists do.
π― How it stimulates the outcome: Example 48's value-based sort is the exact mechanism behind virtually every leaderboard, "most popular item," or "lowest stock first" report you'll ever build from a dictionary.
π Significance: These four patterns cover essentially every dictionary iteration need β unordered, paired, key-sorted, and value-sorted β and combining them fluently is a core Python skill.
update(), the | Operator (3.9+), and ** Unpacking
defaults = {"theme": "light", "volume": 50}
user_settings = {"volume": 80, "language": "en"}
# Example 49: merging via .update() β mutates the FIRST dict
merged_a = defaults.copy()
merged_a.update(user_settings)
print("via update():", merged_a)
# Example 50: merging via the | operator (Python 3.9+) β no mutation
merged_b = defaults | user_settings
print("via | operator:", merged_b)
print("defaults untouched:", defaults)
# Example 51: merging via ** double-star unpacking
merged_c = {**defaults, **user_settings}
print("via ** unpacking:", merged_c)βοΈ How it operates: All three approaches share the same "later values win on key conflict" rule from
.update(). .update() mutates the dictionary it's called on, which is why Example 49 first calls .copy() to avoid mutating defaults itself. The | merge operator (added in Python 3.9) is non-mutating by design β it always returns a brand-new dictionary, leaving both originals untouched, which Example 50 confirms directly. ** unpacking inside a new literal (Example 51) achieves the same non-mutating result and predates the | operator, so you'll still see it in code targeting older Python versions.
π How it affects the program: In every case, when both dictionaries share a key, the value from the second (right-hand or later) dictionary wins β
user_settings["volume"] overrides defaults["volume"] in all three examples.
π― How it stimulates the outcome: The
| operator is the modern, recommended choice for Python 3.9+ β it's the most readable and the least error-prone, since there's no risk of accidentally mutating an input you meant to keep unchanged.
π Significance: This "layered defaults" pattern is foundational to how configuration systems, environment-specific settings, and function keyword-argument defaults are typically implemented in real Python applications.
defaultdict and Counter
from collections import defaultdict, Counter # Example 52: defaultdict removes the need for setdefault() entirely groups = defaultdict(list) words = ["apple", "ant", "bear", "bee"] for word in words: groups[word[0]].append(word) # no existence check needed print(dict(groups)) # Example 53: defaultdict(int) for clean counting counts = defaultdict(int) for letter in "mississippi": counts[letter] += 1 print(dict(counts)) # Example 54: Counter β purpose-built for exactly this task letter_counts = Counter("mississippi") print(letter_counts) # Example 55: Counter.most_common() β instant frequency ranking print(letter_counts.most_common(2))
βοΈ How it operates:
defaultdict(factory) behaves exactly like a normal dictionary, except accessing a missing key automatically creates it, using factory() to generate its initial value β list gives an empty list, int gives 0 β eliminating the manual existence checks that plain dictionaries require. Counter goes one step further, being purpose-built for tallying: passing it an iterable automatically counts occurrences of each item, and its .most_common(n) method instantly returns the top n items ranked by count.
π How it affects the program: Compare Example 53's
defaultdict(int) approach with Example 41's manual setdefault() loop from earlier β both solve grouping/counting, but defaultdict removes even the setdefault() call, since missing-key access itself now creates the default automatically.
π― How it stimulates the outcome:
Counter (Examples 54β55) is almost always the right tool specifically for counting tasks β it's more readable, self-documenting, and comes with built-in ranking, arithmetic, and set-like operations that a plain dictionary would require extra code to replicate.
π Significance: Recognising when a specialised
collections class solves your problem more directly than a plain dictionary is a mark of genuine Python fluency β these aren't just conveniences, they express your intent more clearly to anyone reading the code.
βοΈ Advanced β Hashability, Performance, and JSON
The details that separate a working script from a scalable one
Why Only Immutable Types Can Be Dictionary Keys
# Example 56: strings, numbers, and tuples ARE valid keys valid = {"name": 1, 42: "answer", (1, 2): "point"} print(valid) # Example 57: a list CANNOT be a key β it's mutable, so unhashable try: bad = {[1, 2]: "oops"} except TypeError as e: print(f"Error: {e}") # Example 58: tuples of coordinates as keys β a very common real pattern grid = {} grid[(0, 0)] = "start" grid[(3, 4)] = "treasure" print(grid[(3, 4)])
TypeError messages you'll otherwise find mysterious.
βοΈ How it operates: A dictionary locates values by computing a numeric hash of each key and using that hash to jump almost directly to the right storage bucket β this is what makes lookup O(1). That hash must never change for the lifetime of the object, which is only guaranteed for immutable types like strings, numbers, and tuples (as long as the tuple's own contents are also immutable). Lists are mutable, so Python refuses to hash them at all, raising
TypeError: unhashable type: 'list' outright.
π How it affects the program: Example 58 shows the practical payoff: tuples, being immutable and therefore hashable, are the idiomatic way to key a dictionary by coordinates, dates, or any other multi-part identifier.
π― How it stimulates the outcome: If you ever need a "list-like" key, the fix is simple β convert it to a tuple first (
tuple(my_list)), since tuples support the same ordered, multi-value shape while remaining hashable.
π Significance: This hashability rule is a direct, visible consequence of how dictionaries achieve their O(1) performance β it isn't an arbitrary restriction, it's the price of the speed guarantee.
Measuring O(1) vs O(n)
import time n = 100000 big_list = list(range(n)) big_dict = {i: True for i in range(n)} # Example 59: searching a list β O(n), scans until found start = time.perf_counter() found_in_list = (n - 1) in big_list list_time = time.perf_counter() - start # Example 60: searching a dict's keys β O(1) on average start = time.perf_counter() found_in_dict = (n - 1) in big_dict dict_time = time.perf_counter() - start print(f"list search: {list_time:.6f}s") print(f"dict search: {dict_time:.6f}s") # Example 61: real payoff β using a dict to avoid an O(n^2) nested loop orders = [101, 102, 103, 104] shipped_ids = {102, 104} # set β same O(1) principle as dict keys pending = [o for o in orders if o not in shipped_ids] print("pending orders:", pending)
βοΈ How it operates: Checking
in on a list requires scanning element by element until a match is found (or the list ends) β its cost grows with the list's size. Checking in on a dictionary's keys (or a set) uses the same hash-based lookup described in the previous block β its cost stays roughly constant no matter how large the dictionary grows.
π How it affects the program: On a collection of 100,000 items, this difference becomes measurable and, inside a loop performing many lookups, can turn a slow O(nΒ²) algorithm into a fast O(n) one β exactly what Example 61 demonstrates by using a
set (which shares the dictionary's hashing mechanism) to filter orders in a single efficient pass.
π― How it stimulates the outcome: Whenever you find yourself writing "for each item in list A, check if it's in list B" β a classic O(nΓm) pattern β converting list B to a dictionary or set first is one of the single highest-leverage performance optimisations available in everyday Python code.
π Significance: This isn't a micro-optimisation β it's the difference between a script that finishes instantly and one that visibly hangs once your data grows past a few thousand items.
json.dumps() and json.loads() β the Bridge to the Web
import json # Example 62: converting a Python dict TO a JSON string user = {"name": "Glenn", "age": 30, "active": True, "roles": ["admin", "editor"]} json_string = json.dumps(user) print(json_string, type(json_string)) # Example 63: pretty-printing JSON for readability print(json.dumps(user, indent=2)) # Example 64: parsing a JSON string BACK into a Python dict raw = '{"city": "Diplahan", "population": 45000}' parsed = json.loads(raw) print(parsed, type(parsed)) print("population:", parsed["population"])
βοΈ How it operates:
json.dumps(obj) serialises a Python object into a JSON-formatted string β dictionaries become JSON objects, lists become JSON arrays, and Python's True/False/None become JSON's true/false/null. json.loads(string) reverses the process, parsing a JSON string back into native Python objects β JSON objects become dictionaries, JSON arrays become lists.
π How it affects the program: The
indent= parameter in Example 63 has no effect on the underlying data β it only controls formatting for human readability, which is invaluable when logging or debugging API payloads.
π― How it stimulates the outcome: This round-trip β
dumps() to send data out, loads() to receive it back β is literally what happens every time your Python code calls a web API: the response you receive as text is parsed straight into the nested-dictionary structures covered in the Comprehensions section.
π Significance: Everything you've learned in this tutorial β nested access,
.get() with defaults, safe chained lookups β exists precisely to handle the dictionaries that json.loads() hands you from real, sometimes-incomplete, external data sources.
πΌ Real-World Mini Projects
Three self-contained programs that combine everything above
Count and Rank Words in a Block of Text
Combines string cleaning, dictionary counting, and Counter.most_common() β the exact backbone of basic text analysis and search-relevance ranking.
import string from collections import Counter text = """Python dictionaries are fast. Python dictionaries are flexible. Every Python programmer should master dictionaries early.""" cleaned = text.lower().translate(str.maketrans("", "", string.punctuation)) words = cleaned.split() frequency = Counter(words) print("Top 3 most common words:") for word, count in frequency.most_common(3): print(f" {word}: {count}") unique_words = len(frequency) total_words = sum(frequency.values()) print(f"\nUnique words: {unique_words} | Total words: {total_words}")
βοΈ How it operates: The text is lowercased and stripped of punctuation using
str.translate() before splitting, ensuring "Python" and "python." count as the same word. Counter(words) then tallies every word in a single call, directly reusing the pattern from Example 54, and .most_common(3) ranks the results without any manual sorting code.
π How it affects the program:
len(frequency) counts the number of distinct keys (unique words), while sum(frequency.values()) adds up every count (total words, including repeats) β the same "keys vs values" distinction from the Basics section, applied to a real analytical question.
π― How it stimulates the outcome: This exact pipeline β clean, tokenise, count, rank β scales directly to real search and NLP tasks; the only thing that changes at production scale is the cleaning sophistication, not the core dictionary logic.
π Significance: This project demonstrates how a handful of dictionary operations, chained together, replace what would otherwise be a much longer, more error-prone hand-written counting loop.
Create, Read, Update, and Delete Contact Records
contacts = {}
def add_contact(name, phone, email):
contacts[name] = {"phone": phone, "email": email}
def update_contact(name, **fields):
if name in contacts:
contacts[name].update(fields)
else:
print(f"No contact named {name}")
def remove_contact(name):
removed = contacts.pop(name, None)
return removed is not None
def show_contacts():
for name, info in sorted(contacts.items()):
print(f"{name}: {info['phone']} | {info['email']}")
add_contact("Ana", "555-0101", "ana@example.com")
add_contact("Bob", "555-0102", "bob@example.com")
update_contact("Bob", phone="555-9999")
show_contacts()
print("\nRemoved Ana?", remove_contact("Ana"))
show_contacts()βοΈ How it operates: The outer
contacts dictionary is keyed by name; each value is itself a small dictionary of fields β directly reusing the nested-dictionary pattern from Example 42. update_contact() uses **fields to accept any number of named field updates and passes them straight into .update() on the specific contact's inner dictionary. remove_contact() uses .pop(name, None) β the safe-removal pattern from Example 26 β to avoid crashing if the name doesn't exist, and returns whether the removal actually happened.
π How it affects the program:
show_contacts() reuses the sorted-iteration pattern from Example 47 to always display contacts alphabetically, regardless of the order they were added in.
π― How it stimulates the outcome: Because
update_contact() only changes the fields you actually pass in (via **fields), calling update_contact("Bob", phone="555-9999") updates just the phone number, leaving the email untouched β precise, targeted updates without rewriting the entire record.
π Significance: This project is a direct, minimal model of what a real database's CRUD layer does β Create, Read, Update, Delete β built entirely from the dictionary operations you learned in this tutorial.
Combine Two Store Locations Into One Report
store_a = {"sword": 5, "shield": 2, "potion": 20}
store_b = {"shield": 3, "potion": 8, "bow": 4}
# Combine total stock across both stores
combined = {}
for store in (store_a, store_b):
for item, qty in store.items():
combined[item] = combined.get(item, 0) + qty
print("Combined inventory:", combined)
# Flag items needing restock (fewer than 10 units total)
low_stock = {item: qty for item, qty in combined.items() if qty < 10}
print("Needs restock:", low_stock)
# Rank items from most to least stocked
ranked = sorted(combined.items(), key=lambda pair: pair[1], reverse=True)
print("\nRanked by stock level:")
for item, qty in ranked:
print(f" {item}: {qty}")βοΈ How it operates: Rather than a simple merge (which would let one store's count overwrite the other's), the nested loop adds quantities together using
combined.get(item, 0) + qty β the safe-default pattern from Example 20 β so items present in both stores get properly summed rather than replaced.
π How it affects the program: The low-stock filter reuses the dictionary-comprehension-with-condition pattern from Example 36, applied to real business logic instead of numbers.
π― How it stimulates the outcome: The final ranking step directly reuses the sort-by-value pattern from Example 48, turning the combined inventory into an actionable, ordered report β exactly the kind of output a store manager would actually want to see.
π Significance: This project shows why the naive
| or .update() merge (Examples 49β51) isn't always the right tool β when values need to be combined rather than overwritten, a manual accumulation loop using .get() with a default is the correct pattern instead.
β Frequently Asked Questions
Common questions about Python dictionaries, answered
Are Python dictionaries ordered?
+Yes, since Python 3.7 dictionaries officially guarantee insertion order β keys and values are stored and iterated in the order they were added. Before 3.7 this was an implementation detail; now it's a language guarantee you can rely on.
What is the difference between dict[key] and dict.get(key)?
+Bracket access raises KeyError if the key doesn't exist, crashing your program if uncaught. .get(key) returns None (or a custom default you supply) instead, making it the safer choice whenever a key might legitimately be missing.
Can I use a list as a dictionary key?
+No β lists are mutable and therefore unhashable, and dictionary keys must be hashable. Use a tuple instead, which is immutable and hashable, if you need a multi-value key like coordinates.
Why is dictionary lookup so fast?
+Dictionaries use a hash table internally. Each key is converted to a numeric hash that determines almost directly where its value is stored, so lookup doesn't require scanning every entry β it's O(1) on average, regardless of how many keys the dictionary holds.
What's the best way to merge two dictionaries?
+In Python 3.9+, use the | operator: merged = dict_a | dict_b. It's non-mutating and reads clearly. For older Python versions, use {**dict_a, **dict_b}. In both cases, keys in the second dictionary override matching keys in the first.
Why did modifying my nested dictionary copy also change the original?
+.copy() only creates a shallow copy β the outer dictionary is new, but any nested dictionaries or lists inside it are still shared with the original. Use copy.deepcopy() when your dictionary contains nested mutable objects and you need full independence.
What is the difference between .pop() and del?
+.pop(key) removes the key and returns its value (and can accept a default to avoid crashing on a missing key). del dict[key] also removes the key but returns nothing, and raises KeyError with no built-in way to supply a default.
How do I count occurrences of items in a list using a dictionary?
+The cleanest way is collections.Counter(my_list), which does the counting in one call and provides .most_common() for ranking. The manual equivalent is a loop using counts[item] = counts.get(item, 0) + 1 or collections.defaultdict(int).
Can dictionary values be duplicated?
+Yes β only keys must be unique. Multiple keys can point to identical values without any restriction, which is exactly what makes inverting a dictionary (swapping keys and values) risky if the original values aren't actually unique.
What's the difference between a dictionary and a JSON object?
+They're conceptually the same key-value idea, but JSON is a text format used for data interchange, while a Python dictionary is a live, in-memory object. json.loads() converts JSON text into a Python dictionary, and json.dumps() converts a Python dictionary back into JSON text.
When should I use defaultdict instead of a plain dict?
+Use defaultdict whenever you're repeatedly grouping or counting into a dictionary and would otherwise need a manual "does this key exist yet" check before every update β it removes that check entirely by auto-creating missing keys with a sensible default value.
Why does iterating over a dict only give me the keys?
+By design, a bare for x in my_dict loop iterates over the keys only β this is a deliberate default, equivalent to for x in my_dict.keys(). If you need both key and value together, use for key, value in my_dict.items() instead.
βοΈ Practice Exercises & Quizzes
Test your understanding with hands-on problems and multiple-choice questions
Task: Write a function that swaps the keys and values of a dictionary, assuming all values are unique and hashable.
Example: invert({"a": 1, "b": 2}) β {1: "a", 2: "b"}
.items() solves this β see Example 39.def invert(d: dict) -> dict: # Your code here pass # Test print(invert({"a": 1, "b": 2})) # {1: 'a', 2: 'b'}
def invert(d: dict) -> dict: return {value: key for key, value in d.items()} # Explanation: # 1. .items() yields each (key, value) pair # 2. The comprehension simply writes value first, key second # 3. If two keys shared a value, the later one silently wins
Task: Write a function that merges two dictionaries of numbers, summing values for keys present in both.
Example: merge_sum({"a":1,"b":2}, {"b":3,"c":4}) β {"a":1,"b":5,"c":4}
.get(key, 0) to safely accumulate β see Project 3.def merge_sum(a: dict, b: dict) -> dict: # Your code here pass # Test print(merge_sum({"a":1,"b":2}, {"b":3,"c":4})) # {'a':1,'b':5,'c':4}
def merge_sum(a: dict, b: dict) -> dict: result = a.copy() for key, value in b.items(): result[key] = result.get(key, 0) + value return result # Explanation: # 1. Start from a COPY of 'a' so the original is not mutated # 2. For each key in 'b', add its value to any existing value (default 0) # 3. Keys unique to 'a' or 'b' are preserved correctly either way
Task: Write a function that groups a list of words by their length.
Example: group_by_length(["a","to","cat","dog","hi"]) β {1:["a"],2:["to","hi"],3:["cat","dog"]}
setdefault(key, []).append(item) inside a loop β see Example 41.def group_by_length(words: list) -> dict: # Your code here pass # Test print(group_by_length(["a","to","cat","dog","hi"]))
def group_by_length(words: list) -> dict: groups = {} for word in words: groups.setdefault(len(word), []).append(word) return groups # Explanation: # 1. len(word) becomes the grouping key # 2. setdefault(key, []) creates an empty list only the first time a length is seen # 3. .append(word) then adds the word, whether the list is new or already existed
Task: Write a function that returns the value that appears most often in a list, using a dictionary to count.
Example: most_frequent([1,2,2,3,2,1]) β 2
collections.Counter combined with .most_common(1) solves this directly β see Example 55.def most_frequent(items: list): # Your code here pass # Test print(most_frequent([1,2,2,3,2,1])) # 2
from collections import Counter def most_frequent(items: list): counts = Counter(items) return counts.most_common(1)[0][0] # Explanation: # 1. Counter(items) tallies every value in one call # 2. .most_common(1) returns a list with ONE (value, count) tuple β the top result # 3. [0][0] drills into that tuple to grab just the value itself
Task: Write a function that safely retrieves a deeply nested value from a dictionary given a list of keys, returning a default if any key along the path is missing.
Example: safe_get({"a":{"b":{"c":42}}}, ["a","b","c"]) β 42; a missing path returns the default.
.get() on the result at each step β see Example 43.def safe_get(d: dict, keys: list, default=None): # Your code here pass # Test print(safe_get({"a":{"b":{"c":42}}}, ["a","b","c"])) # 42 print(safe_get({"a":{}}, ["a","b","c"], "missing")) # missing
def safe_get(d: dict, keys: list, default=None): current = d for key in keys: if not isinstance(current, dict): return default current = current.get(key, default) return current # Explanation: # 1. 'current' starts at the top-level dict and descends one key at a time # 2. isinstance() check guards against trying to .get() on a non-dict value # 3. .get(key, default) safely handles a missing key at ANY depth
Multiple-Choice Questions
1. What happens when you access a missing key with square brackets?
NoneKeyError2. What does d.get("x", 0) do if "x" is not in d?
0None3. Which of these CANNOT be used as a dictionary key?
4. What is the average time complexity of a dictionary key lookup?
5. What does dict_a | dict_b do in Python 3.9+?
6. What does for x in my_dict iterate over?
7. What does setdefault(key, []) do when the key already exists?
8. Which collections class is purpose-built for counting items?
defaultdictCounterOrderedDictnamedtupleπ Congratulations β You've Mastered Python Dictionaries!
You've now covered Python's implementation of one of computer science's most important data structures β the hash map β in genuine depth, not just in passing.
β What You've Accomplished
- Understood dictionary creation, access, and β critically β mutability and aliasing
- Mastered 15+ dictionary methods for retrieving, updating, removing, and copying
- Learned to write and read dictionary comprehensions fluently, including inversion and grouping
- Worked confidently with nested dictionaries β the plain-Python shape of JSON
- Merged dictionaries three different ways, including the modern
|operator - Applied
collections.defaultdictandCounterto eliminate manual bookkeeping - Understood exactly why dictionary keys must be hashable and why lookup is O(1)
- Converted between Python dictionaries and JSON with
json.dumps()andjson.loads() - Built three real-world mini projects: a word frequency analyser, a contact book, and an inventory merger
- Worked through 70+ runnable examples, five exercises, and eight quiz questions
π Why This Matters Beyond This Page
Everything you learned here reappears constantly across the rest of Python and software development broadly. Every API you'll ever call returns dictionaries once parsed. Every configuration system you'll ever build is layered dictionaries merged with | or .update(). Every caching system, from a simple in-memory cache to Redis, is conceptually a dictionary. Interview questions about hash maps β one of the most frequently tested data structures in technical interviews β are directly answerable with what you just practiced. Dictionaries are not a side skill; alongside lists, they are the twin foundations that nearly all other Python data structures and libraries are built on top of.
π Next Steps
Now that dictionaries feel natural, consider extending into:
- Tuples & Sets β deepen the hashability and performance concepts from the Advanced section
- Classes β dictionaries and objects (via
__dict__) are more closely related than they first appear - Pandas DataFrames β conceptually a dictionary of columns, each column itself a labelled list
- APIs & web development β apply your JSON fluency to real HTTP requests and responses
- Algorithms β hash maps are the backbone of countless classic algorithm interview problems
π A Final Word
Dictionaries are where Python's philosophy of "readable, explicit code" shows most clearly β user["email"] tells a reader exactly what it means in a way user[3] never could. The gap between "I can write my_dict[key] = value" and "I understand hashability, shallow-copy pitfalls, and why .get() exists" is the same gap that separates a beginner from a confident intermediate developer. You've just closed a large part of that gap for the second major data structure in Python. Keep experimenting, keep breaking things intentionally to see why they break, and keep building.
Now go build something structured with your dictionaries! π
Poetic Bytes Β· 2026 Β· Made with β€οΈ by Glenn Junsay Pansensoy
"A dictionary is a promise: give me the right key, and I'll give you the right answer, instantly."