Python Dictionaries: Keys, Values, Updates, Loops, and JSON

Published Updated

Think of a mailroom wall covered in pigeonholes, each with a name written under it. Nobody counts along the wall to find their post. They read the labels, go straight to the one that says their name, and take what is inside.

That labelled wall is the mental model for a Python dictionary. You store a value under a name you chose, you fetch it back by that name, and no two pigeonholes carry the same label. Positions exist, but they are never how you find anything.

What Is a Python Dictionary?

A dictionary is a collection of key and value pairs. The key is the label, the value is whatever sits behind it, and each key appears at most once. Assigning to a key that already exists replaces its value instead of adding a second entry.

user = {"name": "Ada", "email": "ada@example.com", "age": 36}

print(user["name"])
print(len(user))
# Program output:
# Ada
# 3

Values can be anything at all, including lists, other dictionaries, and your own objects. Keys are restricted to hashable objects, which in practice means strings, numbers, booleans, and tuples of those. Lists and dictionaries cannot be keys because they can change after they are made.

Dictionaries also keep insertion order, which the language has guaranteed since Python 3.7. That is the order keys were first added, not alphabetical order, and re-assigning an existing key leaves it where it was.

How do you create a dictionary?

Braces are the usual spelling. The dict() constructor covers the cases where the keys are already valid names or already sit in pairs:

literal = {"host": "localhost", "port": 5432}
keyword = dict(host="localhost", port=5432)
pairs = dict([("host", "localhost"), ("port", 5432)])
empty = {}

print(literal == keyword == pairs)
print(type(empty))
# Program output:
# True
# <class 'dict'>

Note that {} is an empty dictionary, not an empty set. That is the one piece of Python punctuation that surprises people coming from other languages, and set() is how you spell the empty set.

Two more routes come up constantly. Pairing two sequences with zip builds a record from headers and a row, and dict.fromkeys builds a dictionary whose keys all start on the same value:

headers = ["name", "age", "city"]
row = ["Ada", "36", "London"]

print(dict(zip(headers, row)))
print(dict.fromkeys(["read", "write"], False))
# Program output:
# {'name': 'Ada', 'age': '36', 'city': 'London'}
# {'read': False, 'write': False}

Give fromkeys a mutable default such as a list and every key shares one object, which is the same trap the repeated-empty-list shortcut sets on lists. A dict comprehension is the safe way to give each key its own.

Reading Values Without Crashing

Square brackets fetch a value and raise KeyError when the label is not on the wall. The get method asks the same question and hands back None, or a default you supply, instead of raising:

user = {"name": "Ada", "email": "ada@example.com"}

print(user.get("name"))
print(user.get("phone"))
print(user.get("phone", "Not provided"))
# Program output:
# Ada
# None
# Not provided

When a Missing Key Is a Bug

The choice between the two is not about safety, it is about whose fault a missing key would be. Use brackets when the key must exist and its absence means your program is already wrong, because a loud KeyError at the point of the mistake is worth more than a quiet None three functions later.

Use get when the data comes from outside your control, such as a form field a user may leave blank or an API response with optional fields. There the absence is expected, and a default is a real answer rather than a papered-over failure.

Missing Keys and None Values

The get method cannot distinguish a key that is absent from a key whose stored value is None. The in operator can, and it tests keys rather than values:

user = {"phone": None}

print(user.get("phone", "missing"))
print("phone" in user)
print("fax" in user)
# Program output:
# None
# True
# False

The first line looks wrong until you read it carefully. The default never applies, because the key is present, so get returns the stored None. Reach for in whenever "not set" and "set to nothing" mean different things in your data.

Adding, Updating, and Removing

Assignment does both jobs. A new label writes a new pigeonhole, and an existing label replaces its contents:

user = {"name": "Ada"}

user["email"] = "ada@example.com"
user["name"] = "Ada Lovelace"

print(user)
# Program output:
# {'name': 'Ada Lovelace', 'email': 'ada@example.com'}

Removal comes in three forms, matching whether you want the value back and whether you know the key:

user = {"name": "Ada", "age": 36, "city": "London", "role": "admin"}

age = user.pop("age")
last = user.popitem()
del user["name"]

print(age, last)
print(user)
# Program output:
# 36 ('role', 'admin')
# {'city': 'London'}

The pop method removes by key and returns the value, raising KeyError unless you pass a default as its second argument. The popitem method removes the most recently inserted pair and returns it as a tuple. The del statement removes without returning, and clear() empties the dictionary.

How do you merge two dictionaries?

Three spellings merge dictionaries, and the difference that matters is whether the original changes:

defaults = {"host": "localhost", "port": 5432, "debug": False}
overrides = {"debug": True}

merged = defaults | overrides
unpacked = {**defaults, **overrides}
copy_of_defaults = dict(defaults)
copy_of_defaults.update(overrides)

print(merged)
print(defaults)
# Program output:
# {'host': 'localhost', 'port': 5432, 'debug': True}
# {'host': 'localhost', 'port': 5432, 'debug': False}

The | operator and the ** unpacking both build a new dictionary and leave both inputs alone. The update method writes into the receiver, which is why the example copies first. In every form the right-hand side wins on any key both sides carry.

That last rule is the whole reason the defaults-then-overrides pattern reads so well. Put the settings you shipped on the left and the settings the user chose on the right, and the merge states the precedence in one line.

Keys, Values, and Items

Looping over a dictionary gives you keys. Most of the time items() is clearer, because it hands you both halves of each pair at once:

user = {"name": "Ada", "age": 36}

for key in user:
    print(key)

for key, value in user.items():
    print(key, "=", value)
# Program output:
# name
# age
# name = Ada
# age = 36

The three view methods, keys(), values(), and items(), return live views rather than snapshots. Change the dictionary and every view already handed out reflects the change:

user = {"name": "Ada", "age": 36}
labels = user.keys()

user["city"] = "London"
print(labels)
# Program output:
# dict_keys(['name', 'age', 'city'])

That is useful when you want the view to track the data, and surprising when you expected a frozen copy. Wrap the view in list() when you need one that stops moving.

Counting and Grouping

Two patterns account for most dictionaries written in anger. Counting maps each thing to how many times it appeared, and grouping maps each category to the list of things in it.

items = ["apple", "banana", "apple", "cherry", "banana", "apple"]

counts = {}
for item in items:
    counts[item] = counts.get(item, 0) + 1

print(counts)
# Program output:
# {'apple': 3, 'banana': 2, 'cherry': 1}

The get call with a default of 0 is what removes the special case for the first sighting. Without it the loop needs a membership test before every increment.

Setdefault and Its Sharp Edge

Grouping needs a fresh list the first time a category appears. The setdefault method stores the default when the key is absent and returns whatever is now there, so the two steps collapse into one:

people = [("admin", "Ada"), ("user", "Bo"), ("admin", "Cleo")]

by_role = {}
for role, name in people:
    by_role.setdefault(role, []).append(name)

print(by_role)
# Program output:
# {'admin': ['Ada', 'Cleo'], 'user': ['Bo']}

The sharp edge is that the default argument is evaluated before setdefault runs, every single time. Writing cache.setdefault(key, fetch(key)) therefore calls fetch on every pass, including the ones that were meant to be cache hits.

When the fallback is expensive, spell out the membership test instead so the expensive call only happens on a genuine miss.

Defaultdict for the Whole Loop

When missing keys are the expected case rather than the exception, collections.defaultdict moves the bookkeeping into the type. You declare a factory once and then write the loop as if every key already existed:

from collections import defaultdict

people = [("admin", "Ada"), ("user", "Bo"), ("admin", "Cleo")]

by_role = defaultdict(list)
for role, name in people:
    by_role[role].append(name)

print(dict(by_role))
# Program output:
# {'admin': ['Ada', 'Cleo'], 'user': ['Bo']}

The behaviour to remember is that reading a missing key creates it. After by_role["guest"] the guest key exists with an empty list, even though you only looked. Use get when you want to inspect without writing.

How do you sort a dictionary?

A dictionary has no sort method, because its order is the order things arrived. Sorting means building a new dictionary from sorted pairs:

prices = {"cherry": 2.0, "apple": 1.5, "banana": 0.75}

by_key = dict(sorted(prices.items()))
by_value = dict(sorted(prices.items(), key=lambda pair: pair[1]))

print(by_key)
print(by_value)
# Program output:
# {'apple': 1.5, 'banana': 0.75, 'cherry': 2.0}
# {'banana': 0.75, 'apple': 1.5, 'cherry': 2.0}

The key function says which half of each pair to compare, so pair[1] sorts on values. Add reverse=True for descending order. When only the top few matter, heapq.nlargest avoids sorting the whole thing.

Nested Dictionaries and JSON

Real data nests. An API response holds a user, which holds an address, which holds a postcode, and each level is another dictionary. Chained get calls read a deep path without raising on any missing key:

response = {"data": {"user": {"profile": {"bio": "Engineer"}}}}

bio = response.get("data", {}).get("user", {}).get("profile", {}).get("bio", "No bio")
missing = response.get("data", {}).get("account", {}).get("plan", "free")

print(bio)
print(missing)
# Program output:
# Engineer
# free

Each {} default keeps the chain going when a key is absent. It does not guard a key that is present with the value None: {"data": None} returns None, and the next get raises AttributeError. Validate levels that can be null before chaining.

Past three levels the line stops being readable either way, and naming the intermediate shape in its own variable is usually the better answer.

The json module is the boundary between dictionaries and text. It maps a dictionary to a JSON object and back, with a few conversions worth knowing:

import json

record = {"name": "Ada", "active": True, "score": None, 2026: "year"}
text = json.dumps(record)
back = json.loads(text)

print(text)
print(list(back.keys()))
# Program output:
# {"name": "Ada", "active": true, "score": null, "2026": "year"}
# ['name', 'active', 'score', '2026']

Three conversions happen there. Python's True becomes true, None becomes null, and the integer key becomes a string because JSON objects only allow string keys. That last one means a round trip does not always give you back the dictionary you started with.

Types with no JSON equivalent, such as datetime and set, raise TypeError on json.dumps. Convert them yourself before encoding, usually to an ISO date string and a list.

A Worked Example: Summarising Orders

Here is the type doing a real job. A list of order records has to become a per-customer total and a list of what each one bought, which is counting and grouping in the same pass.

orders = [
    {"customer": "Ada", "item": "keyboard", "total": 80.0},
    {"customer": "Bo", "item": "monitor", "total": 210.0},
    {"customer": "Ada", "item": "mouse", "total": 25.0},
]

spend = {}
bought = {}
for order in orders:
    who = order["customer"]
    spend[who] = spend.get(who, 0) + order["total"]
    bought.setdefault(who, []).append(order["item"])

print(spend)
print(bought)
# Program output:
# {'Ada': 105.0, 'Bo': 210.0}
# {'Ada': ['keyboard', 'mouse'], 'Bo': ['monitor']}

Note the two access styles doing different jobs in the same loop. Reading order["customer"] uses brackets because a record without a customer is broken data and should fail loudly. Reading spend.get(who, 0) uses a default because a first-time customer is entirely normal.

Ranking the result is the sort pattern from earlier, applied to the summary rather than to the raw orders:

spend = {"Ada": 105.0, "Bo": 210.0}

ranked = dict(sorted(spend.items(), key=lambda pair: pair[1], reverse=True))
print(ranked)
# Program output:
# {'Bo': 210.0, 'Ada': 105.0}

Pitfalls and Debugging

KeyError on Data You Do Not Control

The message is exact and names the key: KeyError: 'phone'. The mistake is almost never a typo in the key, it is an assumption that a field is always present.

Fix it at the boundary rather than at the crash site. Decide once, where the data enters your program, which fields are required and which have defaults, and apply get or a validation step there.

Unhashable Key Types

Using a list as a key raises TypeError: unhashable type: 'list'. A key has to be stable, because the dictionary files it by its hash, and a list can change after filing:

prices = {}
prices[("London", "SW1")] = 42

print(prices)
# Program output:
# {('London', 'SW1'): 42}

A tuple is the fix when you want a compound key, as long as everything inside it is hashable too. A tuple containing a list is not hashable and raises the same error.

Changing Keys While Looping

Adding or deleting keys while iterating is invalid: it may raise RuntimeError: dictionary changed size during iteration or skip entries. Changing the values behind existing keys is allowed, because the set of keys is what the loop is walking.

counts = {"apple": 3, "banana": 2}

for key in counts:
    counts[key] = counts[key] * 2
print(counts)

for key in list(counts):
    if counts[key] > 5:
        del counts[key]
print(counts)
# Program output:
# {'apple': 6, 'banana': 4}
# {'banana': 4}

Wrapping the dictionary in list() takes a snapshot of the keys, so the loop walks a fixed sequence while the dictionary underneath it changes.

A Shallow Copy That Shares Its Insides

The copy() method and dict(original) both copy the outer dictionary only. Nested dictionaries and lists are still shared, so a change through one copy shows up in the other:

import copy

settings = {"window": {"width": 800}}
shallow = settings.copy()
deep = copy.deepcopy(settings)

shallow["window"]["width"] = 1024

print(settings["window"]["width"])
print(deep["window"]["width"])
# Program output:
# 1024
# 800

Use copy.deepcopy when the nested objects genuinely need to be independent, and remember it walks the whole structure, so it is not free on large data.

When a Dictionary Is the Wrong Shape

Dictionaries are the right default for named data, and three cases point elsewhere.

  • When the keys are always the same handful of names, a class or a dataclass gives you attribute access, a readable repr, and an error when you misspell a field name.
  • When you only ever ask whether something is present, a set stores the labels without the empty pigeonholes behind them.
  • When order and position are the point, a list says so, and a dictionary keyed by 0, 1, and 2 is a list wearing a disguise.

The comparison page works through the first two against real decision criteria, including what each wrong choice costs to undo.

Frequently Asked Questions

Are Python dictionaries ordered?

Dictionaries keep insertion order, and that has been part of the language specification since Python 3.7. Order means the sequence keys were added in, not sorted order, and re-assigning an existing key keeps its original position rather than moving it to the end.

What can be used as a dictionary key?

Any hashable object, which in practice means strings, numbers, booleans, and tuples whose own contents are hashable. Lists, dictionaries, and sets are not hashable because they can change, so using one as a key raises TypeError.

Can two keys hold the same value?

Yes. Values carry no uniqueness rule at all, so any number of keys can map to the same value. Only keys are unique, and assigning to a key that already exists replaces the value rather than adding a second entry.

How do you check whether a key exists?

Use the in operator, which tests keys rather than values and does not create anything. Checking with get is not equivalent, because get cannot distinguish a missing key from a key whose stored value happens to be None.

What is the difference between pop and popitem?

The pop method takes a key, removes that entry, and returns its value, raising KeyError when the key is absent unless you pass a default. The popitem method takes no key and removes the last inserted pair, returning it as a key and value tuple.

Are dictionaries thread-safe?

Individual built-in operations will not corrupt the dictionary in CPython, but a read-then-write sequence in your own code can still interleave with another thread. Guard any multi-step update with a lock rather than relying on interpreter behaviour that other implementations do not promise.

How do you turn two lists into a dictionary?

Pair them with zip and pass the result to dict, as in dict(zip(headers, row)). The pairing stops at the shorter list, so check the lengths first when a mismatch would mean the data is wrong rather than merely short.

Why does JSON turn my integer keys into strings?

The JSON format only allows string keys, so json.dumps converts integer, float, and boolean keys to their string form. Reading the result back gives you string keys, which means a round trip does not return the dictionary you started with.

Self-Check

  1. What does {} create, and how do you spell an empty set?
  2. Given user = {"phone": None}, what does user.get("phone", "missing") return, and why?
  3. Which of update and | changes the dictionary on the left?
  4. Why does cache.setdefault(key, fetch(key)) call fetch on every pass?
  5. What happens to a defaultdict(list) when you merely read a key that is not there?
  6. Why can a tuple be a dictionary key when a list cannot?
  7. After json.loads(json.dumps({1: "a"})), what is the key, and why?

Answers

  1. An empty dictionary. The empty set has no literal spelling, so it is written set().
  2. None. The key is present, so the default never applies and the stored value comes back. Use "phone" in user to test presence.
  3. update. It writes into the receiver, while | builds a new dictionary and leaves both inputs alone.
  4. Because arguments are evaluated before the call. Python computes fetch(key) first, then passes the result in, so a cache hit still pays for the fetch.
  5. The key is created with an empty list. Reading a missing key runs the factory and stores the result, so use get when you want to look without writing.
  6. Because a tuple cannot change after it is built. Hashing files the key once, so a key that can change would be filed in the wrong place. A tuple containing a list is still unhashable.
  7. The string "1". JSON objects only allow string keys, so the integer key is converted on the way out and stays a string on the way back.

Where to Go Next

The labelled wall holds up all the way down. Names are how you reach a value, labels are unique while contents are not, and the order pigeonholes were added in is remembered but never used to find anything.

The two habits worth taking away are choosing between brackets and get on purpose, and reaching for counting and grouping the moment a loop starts building a summary. Most dictionary bugs are a missing key nobody decided how to handle.

Sources

  1. [1]
  2. [2]
    Mapping Types: dict
    (docs.python.org)
  3. [3]
  4. [4]