Lists and dictionaries are different from ordinary variables in one critical way: a single variable holds an entire collection of values, and that collection can change in place. When you trace a program that uses lists or dictionaries, you record the state of the whole collection after every line that modifies it — not just a single number or string, but the complete contents. Getting this right is what separates a reliable trace from a guess.
This article is part of the Code Reading, Tracing & Prediction section. It assumes you are comfortable with basic trace tables. The techniques here are the same — one row per line, record what changes — but the cells contain whole collections rather than single values. If trace tables are new to you, read Trace Tables first.
| Level | What it covers | When to read it |
|---|---|---|
| Basic idea | How to record list and dictionary state in a trace table; common operations and what they do | Right now. |
| At a deeper level | Indexing, slicing, and iteration; modifying collections inside loops; common mistakes | Once you have traced a simple list operation on your own. |
| At the deepest level | Mutability; why two variables can point at the same list; what this means when passing lists to functions | When the basic and deeper levels feel solid and you want to understand what Python is doing underneath. |
1 How to Record a List in a Trace Table
When a variable holds a list, its cell in the trace table contains the entire list — written out in full, with square brackets, exactly as Python would show it.
Every time any part of the list changes — an item is added, removed, or replaced — you write the complete new state of the list in the next row. You do not write just the part that changed. The whole list goes in the cell.
numbers = [10, 20, 30] numbers.append(40) numbers[0] = 99 print(numbers)
| Line | numbers | Output |
|---|---|---|
numbers = [10, 20, 30] | [10, 20, 30] | |
numbers.append(40) | [10, 20, 30, 40] | |
numbers[0] = 99 | [99, 20, 30, 40] | |
print(numbers) | [99, 20, 30, 40] |
Each row shows the complete list after the modification on that line. Reading the numbers column top to bottom tells the full history of how the list changed.
Writing the whole list in every modified row might feel repetitive, especially for a long list. It is deliberate. When you abbreviate — writing "added 40" instead of the full list — you lose the ability to catch errors. A mistake in your mental model of the list state is invisible until you compare the full state to the actual output.
Students frequently write the new item being added or changed, rather than the updated list. The cell should always show the list as it exists after that line runs — all items, in order, in brackets. A cell that says "added 40" is not a trace. It is a note. Notes cannot be compared to actual output.
Unlike integers and strings, lists are mutable — they can be changed in place without creating a new object. When you write numbers.append(40), Python modifies the existing list object rather than creating a new one. The variable numbers still points to the same object in memory; the object itself now has an additional item.
This is why the trace table records the state of the list at each step rather than a single value. A list variable is better thought of as a label attached to a collection that lives in memory, and the collection's contents can change while the label stays the same. Every row in the trace table is a snapshot of what that collection contains at that moment.
2 Common List Operations and What They Do
Before you can trace a list operation, you need to know what it does. Here are the operations you will encounter most often, with the effect each one has on the list.
| Operation | What it does | Example (list starts as [1, 2, 3]) | List after |
|---|---|---|---|
list.append(x) | Adds x to the end | nums.append(4) | [1, 2, 3, 4] |
list.insert(i, x) | Inserts x at position i; everything from i onward shifts right | nums.insert(1, 9) | [1, 9, 2, 3] |
list.remove(x) | Removes the first occurrence of x | nums.remove(2) | [1, 3] |
list.pop() | Removes and returns the last item | v = nums.pop() | [1, 2] (v = 3) |
list.pop(i) | Removes and returns item at position i | v = nums.pop(0) | [2, 3] (v = 1) |
list[i] = x | Replaces the item at position i with x | nums[1] = 99 | [1, 99, 3] |
list.sort() | Sorts the list in place, ascending | nums.sort() | [1, 2, 3] (already sorted) |
list.reverse() | Reverses the list in place | nums.reverse() | [3, 2, 1] |
len(list) | Returns the number of items — does not change the list | n = len(nums) | [1, 2, 3] (unchanged) |
Operations that modify the list in place — append, insert, remove, pop, sort, reverse, and index assignment — all get a new row showing the updated list. Operations that only read the list — len, indexing to get a value, looping — do not change the list, so the list column stays blank for those rows.
pop() deserves special attention because it both modifies the list and returns a value. When you trace a line like v = nums.pop(0), two things happen simultaneously: the list loses its first item, and v gains that item's value. Both changes go in the same row.
nums = [10, 20, 30] v = nums.pop(0) print(v) print(nums)
| Line | nums | v | Output |
|---|---|---|---|
nums = [10, 20, 30] | [10, 20, 30] | ||
v = nums.pop(0) | [20, 30] | 10 | |
print(v) | 10 | ||
print(nums) | [20, 30] |
Item 10 was at index 0. After pop(0), it is gone from the list and stored in v. The remaining items shift left: what was at index 1 is now at index 0, and what was at index 2 is now at index 1.
Whenever an item is inserted before a given position or removed from before a given position, every subsequent item's index changes. After pop(0) on a three-item list, the item that was at index 1 is now at index 0. If your trace has any subsequent index access, recalculate the positions based on the updated list — do not use the positions from the original list.
The distinction between methods that modify in place and those that return a new object is important in Python and easy to confuse. list.sort() sorts in place and returns None. sorted(list) returns a new sorted list and leaves the original unchanged. Writing nums = nums.sort() is a common mistake — it sets nums to None because sort() returns nothing. The trace table catches this immediately: the return value of sort() is None, so assigning it to nums would show None in that cell.
3 Indexing and Reading from a List
Indexing reads a value out of a list without changing the list. The list column stays blank for those rows — nothing changed. Only the variable that received the value gets a new entry.
List indexes start at 0. The first item is at index 0, the second at index 1, and so on. The last item is always at index len(list) - 1.
colours = ["red", "green", "blue"] first = colours[0] last = colours[2] print(first, last)
| Line | colours | first | last | Output |
|---|---|---|---|---|
colours = ["red", "green", "blue"] | ["red", "green", "blue"] | |||
first = colours[0] | "red" | |||
last = colours[2] | "blue" | |||
print(first, last) | red blue |
The colours list does not change. Its column is blank after the first row because nothing modified it.
Python also supports negative indexing. Index -1 refers to the last item, -2 to the second-to-last, and so on. This is worth knowing because it appears in real code and can cause confusion when tracing if you do not recognise it.
| List | Index | Value |
|---|---|---|
| ["red", "green", "blue"] | 0 | "red" |
1 | "green" | |
2 | "blue" | |
-1 | "blue" | |
-2 | "green" | |
-3 | "red" |
When you encounter a negative index in a trace, convert it to its positive equivalent before filling in the cell. For a list of length 3, list[-1] is the same as list[2]. For a list of length n, list[-k] is the same as list[n - k].
If you access an index that does not exist — for example, index 5 on a three-item list — Python raises an IndexError. When tracing, if you see an index access and the index seems too large (or too negative), check the current length of the list at that point in the trace. The index must be between -len(list) and len(list) - 1 inclusive. If it is outside that range, the program would crash at that line.
Python lists are implemented as dynamic arrays — a contiguous block of memory holding references to objects, with some extra capacity reserved so that append() does not require a memory reallocation every time. Index access is therefore O(1) — constant time regardless of list length — because computing the memory address of item i requires only a single arithmetic operation: base address + (i × reference size).
Negative indexing is implemented as len(list) + index before the lookup. Python computes the positive equivalent automatically. This is why list[-1] works even though there is no negative memory address.
4 Tracing Lists Inside Loops
The most common pattern involving lists is a loop that processes each item. When the loop body modifies the list, the list column gets a new entry on each iteration where a change occurs. When the loop body only reads items, the list column stays blank for those rows.
scores = [40, 75, 60]
total = 0
for s in scores:
total = total + s
print(total)| Line | scores | total | s | Output |
|---|---|---|---|---|
scores = [40, 75, 60] | [40, 75, 60] | |||
total = 0 | 0 | |||
for s in scores: — iteration 1 | 40 | |||
total = total + s | 40 | |||
for s in scores: — iteration 2 | 75 | |||
total = total + s | 115 | |||
for s in scores: — iteration 3 | 60 | |||
total = total + s | 175 | |||
print(total) | 175 |
The scores list is never modified, so its column stays blank after the first row. Only total and s change during the loop.
Now a loop that does modify the list — replacing every item with its doubled value:
values = [3, 7, 2]
for i in range(len(values)):
values[i] = values[i] * 2
print(values)This loop uses range(len(values)) to generate indexes rather than iterating directly over items. This is the pattern needed when you want to modify items in place — iterating directly with for v in values gives you a copy of each item, not a reference to the position, so you cannot use it to change the list.
The sequence is range(3) → 0, 1, 2. Three iterations.
| Line | values | i | Output |
|---|---|---|---|
values = [3, 7, 2] | [3, 7, 2] | ||
for i in range(len(values)): — iteration 1 | 0 | ||
values[i] = values[i] * 2 | values[0] = 3 * 2 = 6 | ||
| [6, 7, 2] | |||
for i in range(len(values)): — iteration 2 | 1 | ||
values[i] = values[i] * 2 | values[1] = 7 * 2 = 14 | ||
| [6, 14, 2] | |||
for i in range(len(values)): — iteration 3 | 2 | ||
values[i] = values[i] * 2 | values[2] = 2 * 2 = 4 | ||
| [6, 14, 4] | |||
print(values) | [6, 14, 4] | ||
For lines where the operation on the list is complex — substituting the index and reading the old value — it helps to write a short working note before writing the updated list. The italic notes above show the substitution: values[0] = 3 * 2 = 6. This prevents errors in the mental arithmetic by making each step visible.
When an index assignment involves reading from the same list you are modifying, write a working note on its own line showing the substitution before writing the updated list. One row for the working, one row for the result. This keeps the table readable and your arithmetic correct.
The pattern for i in range(len(values)) is a common idiom but not the only way to modify a list during iteration. Python's enumerate() function provides both the index and the value simultaneously, which is cleaner in many cases: for i, v in enumerate(values) gives you i as the index and v as the current value without a separate index lookup.
One pattern to avoid: modifying the length of a list while iterating directly over it with for item in list. Adding or removing items mid-iteration causes the iterator to skip items or process the same item twice, producing results that are very hard to predict without careful tracing. If you need to add or remove items, iterate over a copy of the list or build a new list rather than modifying the original in place.
5 How to Record a Dictionary in a Trace Table
A dictionary stores key-value pairs. In a trace table, you record the entire dictionary — all keys and all values — inside curly braces, exactly as Python would show it.
Every time the dictionary changes — a key is added, a value is updated, or a key is removed — you write the complete new state of the dictionary in the next row.
student = {}
student["name"] = "Alex"
student["grade"] = 9
student["grade"] = 10
print(student)| Line | student | Output |
|---|---|---|
student = {} | {} | |
student["name"] = "Alex" | {"name": "Alex"} | |
student["grade"] = 9 | {"name": "Alex", "grade": 9} | |
student["grade"] = 10 | {"name": "Alex", "grade": 10} | |
print(student) | {"name": "Alex", "grade": 10} |
The third row updates the value for the key "grade" — it does not add a second "grade" key. Dictionaries cannot have duplicate keys. Assigning to an existing key replaces its value.
Here are the dictionary operations you will encounter most often:
| Operation | What it does | Example (dict starts as {"a": 1}) | Dict after |
|---|---|---|---|
d[key] = value | Adds a new key-value pair, or updates existing key | d["b"] = 2 | {"a": 1, "b": 2} |
d[key] | Reads the value for key — does not change the dict | x = d["a"] | {"a": 1} (unchanged) |
del d[key] | Removes the key-value pair entirely | del d["a"] | {} |
key in d | Returns True if key exists, False if not — does not change the dict | "a" in d | {"a": 1} (unchanged) |
d.get(key, default) | Returns the value for key if it exists, otherwise returns default — does not change the dict | d.get("z", 0) | {"a": 1} (unchanged), returns 0 |
d.keys() | Returns a view of all keys — does not change the dict | list(d.keys()) | {"a": 1} (unchanged) |
If you write d["z"] and "z" is not in the dictionary, Python raises a KeyError. When tracing, always check whether the key exists in the current state of the dictionary before recording a read. If it does not exist, the program would crash at that line. Use d.get(key, default) to read safely when the key's presence is uncertain.
Python dictionaries are implemented as hash tables. When you write d["name"] = "Alex", Python computes a hash of the string "name" — a numeric fingerprint — and uses it to determine where to store the value in memory. Lookups work the same way: hash the key, go to that memory location, retrieve the value. This makes both reads and writes O(1) — constant time regardless of how many entries the dictionary has.
Since Python 3.7, dictionaries maintain insertion order — keys appear in the order they were first added. This is why the trace table representation of a dictionary is predictable: {"name": "Alex", "grade": 10} will always appear in that order if "name" was added before "grade".
6 Tracing Dictionaries Inside Loops
A common pattern is using a dictionary as a counter or accumulator — building it up as a loop runs, adding keys or updating values on each iteration. Each time the dictionary changes, write the complete updated dictionary in the trace table.
words = ["cat", "dog", "cat", "bird", "dog", "cat"]
counts = {}
for w in words:
if w in counts:
counts[w] = counts[w] + 1
else:
counts[w] = 1
print(counts)This program counts how many times each word appears. The sequence is six iterations — one per item in words.
| Line | counts | w | Output |
|---|---|---|---|
counts = {} | {} | ||
for w in words: — iteration 1 | "cat" | ||
if w in counts: | "cat" in {} → False. Take else branch. | ||
counts[w] = 1 | {"cat": 1} | ||
for w in words: — iteration 2 | "dog" | ||
if w in counts: | "dog" in {"cat": 1} → False. Take else branch. | ||
counts[w] = 1 | {"cat": 1, "dog": 1} | ||
for w in words: — iteration 3 | "cat" | ||
if w in counts: | "cat" in {"cat": 1, "dog": 1} → True. Take if branch. | ||
counts[w] = counts[w] + 1 | counts["cat"] = 1 + 1 = 2 | ||
| {"cat": 2, "dog": 1} | |||
for w in words: — iteration 4 | "bird" | ||
if w in counts: | "bird" in {"cat": 2, "dog": 1} → False. Take else branch. | ||
counts[w] = 1 | {"cat": 2, "dog": 1, "bird": 1} | ||
for w in words: — iteration 5 | "dog" | ||
if w in counts: | "dog" in {"cat": 2, "dog": 1, "bird": 1} → True. Take if branch. | ||
counts[w] = counts[w] + 1 | counts["dog"] = 1 + 1 = 2 | ||
| {"cat": 2, "dog": 2, "bird": 1} | |||
for w in words: — iteration 6 | "cat" | ||
if w in counts: | "cat" in {"cat": 2, "dog": 2, "bird": 1} → True. Take if branch. | ||
counts[w] = counts[w] + 1 | counts["cat"] = 2 + 1 = 3 | ||
| {"cat": 3, "dog": 2, "bird": 1} | |||
print(counts) | {"cat": 3, "dog": 2, "bird": 1} | ||
The output is {"cat": 3, "dog": 2, "bird": 1}. The word "cat" appeared three times, "dog" twice, "bird" once. The trace makes the accumulation process completely visible — each time a word is seen again, the existing count is read, incremented by 1, and written back.
The word-counting pattern in Section 6 is one of the most common dictionary idioms in Python. The if w in counts / else logic can be replaced by counts.get(w, 0), which returns the existing count if the key exists or 0 if it does not — allowing the increment to be written as a single line: counts[w] = counts.get(w, 0) + 1. This eliminates the conditional entirely. Both approaches produce the same result; the single-line version is more concise, the conditional version is easier to trace and reason about for beginners.
7 Mutability: Why Two Variables Can Share a List
When you write b = a where a is a list, you do not get two separate lists. Both a and b point to the same list. Changing the list through b also changes what you see through a — because they are the same list, just with two different names attached to it.
a = [1, 2, 3] b = a b.append(4) print(a) print(b)
The output is:
[1, 2, 3, 4] [1, 2, 3, 4]
Both a and b show the updated list because they refer to the same object.
The trace table needs to show this. When two variables share a list, both columns update whenever the list changes — because both columns are showing the state of the same object.
| Line | a | b | Output |
|---|---|---|---|
a = [1, 2, 3] | [1, 2, 3] | ||
b = a | [1, 2, 3] (same list as a) | ||
b.append(4) | [1, 2, 3, 4] | [1, 2, 3, 4] | |
print(a) | [1, 2, 3, 4] | ||
print(b) | [1, 2, 3, 4] |
The note "(same list as a)" in the b cell is worth writing. It flags that these two variables are not independent, so that when you see b change, you immediately know a changes too.
If you want two independent lists, use b = a.copy() or b = a[:]. Either creates a new list with the same items. After that, modifying b does not affect a.
When a list is passed to a function, the function receives a reference to the same list — not a copy. If the function calls append(), remove(), or modifies items by index, those changes are visible in the calling code after the function returns. This is different from how integers and strings behave when passed to functions. If you do not want a function to modify the original list, pass list.copy() instead.
This behaviour is a direct consequence of Python's object model. Every value in Python is an object, and variables are names bound to objects. When you write b = a, you bind the name b to the same object that a is bound to. You have two names, one object.
For immutable types — integers, strings, tuples — this sharing is invisible because the object can never be changed. x = 5; y = x; x = 10 does not change y because the line x = 10 binds x to a new object (the integer 10); it does not modify the existing object (5). For mutable types — lists, dictionaries — the same assignment shares the object, but now modifications to the object are visible through every name that points to it.
a.copy() creates a shallow copy: a new list containing references to the same objects that the original list contains. For a list of integers or strings this is sufficient. For a list of lists, a shallow copy still shares the inner lists — modifying an inner list through one variable would still affect the other. A deep copy (via import copy; copy.deepcopy(a)) recursively copies every nested object. For Grade 9 work, shallow copies are almost always sufficient.
Trace this program. Write the complete list in every row where it changes.
items = ["apple", "banana", "cherry"] items.append("date") items.remove("banana") items[0] = "avocado" print(items) print(len(items))Trace this program. Pay careful attention to which rows update the list and which only read from it.
nums = [5, 3, 8, 1, 9, 2] largest = nums[0] for n in nums: if n > largest: largest = n print(largest)Trace this program. Use a working note row for each index assignment to show the substitution before writing the updated list.
prices = [100, 250, 75] for i in range(len(prices)): prices[i] = round(prices[i] * 0.9) print(prices)Trace this program with the dictionary. Write the full dictionary state after every modification.
inventory = {} inventory["apples"] = 50 inventory["oranges"] = 30 inventory["apples"] = inventory["apples"] - 10 del inventory["oranges"] inventory["bananas"] = 20 print(inventory)This program is supposed to build a list of only the even numbers from the original list, but it produces wrong output. Trace it to find the bug, then explain what the fix should be.
numbers = [1, 2, 3, 4, 5, 6] evens = numbers for n in numbers: if n % 2 != 0: evens.remove(n) print(evens)- A student writes
b = awhereais a list, then modifiesband is surprised thataalso changed. In two or three sentences, explain why this happened and how the student could have avoided it.