To iterate through a dictionary in Python and get both parts, loop over .items():
for name, score in scores.items():
print(name, score)
Looping over the dictionary on its own gives you the keys and nothing else, which is the detail most people trip over first.
Below: the four ways to loop, sorting as you go, nested dictionaries, and the RuntimeError that appears when you delete inside the loop. Output is from Python 3.12.5.

A plain loop gives you the keys
This is where the confusion starts:
scores = {"ann": 90, "bob": 85, "cat": 95}
for name in scores:
print(name)
print()
print("looping a dictionary directly gives you the KEYS, not the values.")
print("This is the single most common surprise with dictionaries.")
print()
print("to reach a value, look it up:")
for name in scores:
print(" ", name, "scored", scores[name])
Output:
ann
bob
cat
looping a dictionary directly gives you the KEYS, not the values.
This is the single most common surprise with dictionaries.
to reach a value, look it up:
ann scored 90
bob scored 85
cat scored 95

for name in scores yields keys, so values need a lookup.for name in scores is shorthand for for name in scores.keys(). Both do the same thing, and the shorter form is the one you will see most often.
Looking the value up afterwards works, but it searches the dictionary a second time for something you already had. Checking whether a key exists covers the lookup itself.
Iterate over keys and values with items()
.items() hands back both halves on every pass:
scores = {"ann": 90, "bob": 85, "cat": 95}
for name, score in scores.items():
print(name, "scored", score)
print()
print(".items() hands back both parts at once, so there is no lookup.")
print("It is the form to reach for by default.")
print()
print("what the three views actually contain:")
print(" scores.keys() ->", list(scores.keys()))
print(" scores.values() ->", list(scores.values()))
print(" scores.items() ->", list(scores.items()))
Output:
ann scored 90
bob scored 85
cat scored 95
.items() hands back both parts at once, so there is no lookup.
It is the form to reach for by default.
what the three views actually contain:
scores.keys() -> ['ann', 'bob', 'cat']
scores.values() -> [90, 85, 95]
scores.items() -> [('ann', 90), ('bob', 85), ('cat', 95)]

.items() unpacks into two variables, with no lookup.| Loop | Gives you | Use when |
|---|---|---|
for k in d: | Keys | You only need the keys |
for k in d.keys(): | Keys | You want the intent spelled out |
for v in d.values(): | Values | The keys are irrelevant |
for k, v in d.items(): | Both | Almost always |
Pulling one side out on its own is also useful: sum(d.values()) or a list of the keys needs no loop at all.
keys(), values() and items() are live views
They are not copies, which occasionally matters:
scores = {"ann": 90, "bob": 85}
names = scores.keys()
print("names right after taking .keys() ->", list(names))
scores["cat"] = 95
print("after adding cat, the SAME view ->", list(names))
print()
print("views are windows onto the dictionary, not copies.")
print("They update themselves when the dictionary changes.")
print()
print("their types are not lists:")
print(" type(scores.keys()) ->", type(scores.keys()).__name__)
print(" type(scores.items()) ->", type(scores.items()).__name__)
print()
print("wrap in list() when you need a snapshot that will not move.")
Output:
names right after taking .keys() -> ['ann', 'bob']
after adding cat, the SAME view -> ['ann', 'bob', 'cat']
views are windows onto the dictionary, not copies.
They update themselves when the dictionary changes.
their types are not lists:
type(scores.keys()) -> dict_keys
type(scores.items()) -> dict_items
wrap in list() when you need a snapshot that will not move.

A view is a window onto the dictionary. Adding a key afterwards shows up in a view you took earlier, and the view costs almost nothing to create regardless of size.
Wrap it in list() when you want a snapshot, which is exactly the trick used below to delete safely.
Dictionary order and sorting while you loop
Since Python 3.7, dictionaries keep the order things were added:
scores = {}
scores["zoe"] = 70
scores["ann"] = 90
scores["bob"] = 85
print("dictionaries keep insertion order:")
print(" ", list(scores))
print()
print("zoe was added first, so zoe comes first, even though")
print("that is not alphabetical.")
print()
print("sorted by key:")
for name, score in sorted(scores.items()):
print(" ", name, score)
print()
print("sorted by value, highest first:")
for name, score in sorted(scores.items(), key=lambda pair: pair[1], reverse=True):
print(" ", name, score)
print()
print("backwards through insertion order:")
print(" ", list(reversed(scores)))
Output:
dictionaries keep insertion order:
['zoe', 'ann', 'bob']
zoe was added first, so zoe comes first, even though
that is not alphabetical.
sorted by key:
ann 90
bob 85
zoe 70
sorted by value, highest first:
ann 90
bob 85
zoe 70
backwards through insertion order:
['bob', 'ann', 'zoe']

sorted() on the items sorts by key, or by value with a key function.sorted(d.items()) orders by key. To order by value, pass key=lambda pair: pair[1], and add reverse=True for largest first.
Neither sorts the dictionary itself; both produce a sorted list of pairs to loop over. reversed(d) walks insertion order backwards.
RuntimeError: dictionary changed size during iteration
Deleting a key inside the loop stops it dead:
scores = {"ann": 90, "bob": 85, "cat": 95}
try:
for name in scores:
if name == "bob":
del scores[name]
except RuntimeError as err:
print("deleting while looping -> RuntimeError:", err)
print("and the dictionary is left half-processed:", scores)
print()
scores = {"ann": 90, "bob": 85, "cat": 95}
for name in list(scores):
if name == "bob":
del scores[name]
print("safe, looping over list(scores) ->", scores)
print()
scores = {"ann": 90, "bob": 85, "cat": 95}
print("safe, building a new dictionary ->",
{k: v for k, v in scores.items() if k != "bob"})
Output:
deleting while looping -> RuntimeError: dictionary changed size during iteration
and the dictionary is left half-processed: {'ann': 90, 'cat': 95}
safe, looping over list(scores) -> {'ann': 90, 'cat': 95}
safe, building a new dictionary -> {'ann': 90, 'cat': 95}

Python refuses because the loop is walking the dictionary’s internal layout, and removing an entry can move the others. Rather than silently skip items the way lists do, it raises.
Loop over list(d) to iterate a snapshot while changing the original, or build a new dictionary with a comprehension. The second reads better and is usually what you meant. Checking whether a dictionary is empty is worth pairing with it when everything might be removed.
Changing values while looping is allowed
Only a change in size is a problem:
scores = {"ann": 90, "bob": 85, "cat": 95}
for name in scores:
scores[name] = scores[name] + 1
print("adding one to every value while looping ->", scores)
print()
print("this is allowed. Only changing the SIZE of the dictionary")
print("raises, and updating a value leaves the size alone.")
print()
print("a comprehension says the same thing in one line:")
scores = {"ann": 90, "bob": 85, "cat": 95}
print(" ", {name: score + 1 for name, score in scores.items()})
Output:
adding one to every value while looping -> {'ann': 91, 'bob': 86, 'cat': 96}
this is allowed. Only changing the SIZE of the dictionary
raises, and updating a value leaves the size alone.
a comprehension says the same thing in one line:
{'ann': 91, 'bob': 86, 'cat': 96}