Module 3: Collections and Iteration
Lists and Tuples: Ordered Data You Can and Cannot Change
Building ordered collections, reading them by position and by slice, changing them in place, and the one case where refusing to change is the safer design.
Lesson 10 of 46 in the recommended order · About 25 min (estimate)
On this page
Outcome
By the end of this lesson you can build an ordered collection, read any part of it by position or by slice without off-by-one errors, and say when refusing to allow changes is the better design.
Why it matters
Until now the review assistant has handled exactly one opportunity. Real work arrives in batches: forty notices published this week, of which six are worth reading. Everything from here on depends on holding many records at once and moving through them.
Ordered collections are also where two classic bug families live. Off-by-one errors come from misreading where a slice or a range stops. Aliasing bugs come from two names pointing at the same list, so a change made through one name appears through the other, apparently from nowhere.
Concept
A list is an ordered, changeable sequence, written in square brackets. Positions start at zero, so a four-item list has positions 0, 1, 2, 3. Negative positions count from the end, so -1 is the last item.
A slice takes a range of positions: items[1:3] gives positions 1 and 2. The end is exclusive, and this is deliberate and consistent: items[:3] is the first three, items[3:] is everything from position three onward, and the two together reconstruct the whole list with nothing duplicated or lost.
len(items) gives the count. The last valid position is therefore len(items) - 1, which is the arithmetic behind most "index out of range" errors.
Lists change in place. items.append(value) adds one item to the end and returns None; it does not return a new list. items.insert(position, value) and items.remove(value) also modify in place. This matters twice over: assigning items = items.append(x) leaves items holding None, and passing a list to something that mutates it changes the caller's list too, because both names refer to the same object.
A tuple is an ordered sequence that cannot be changed, written with parentheses: ("agency", "value"). Indexing and slicing work identically. Use a tuple when the collection is a fixed group of related things, a coordinate pair, a row of fields, a set of allowed categories, and you want any attempt to modify it to fail loudly rather than silently succeed somewhere far away.
Read the code
notice_ids = ["GSA-2026-0731", "DLA-2026-0088", "SPE-2026-0412", "VA-2026-0155"]
required_fields = ("notice_id", "agency", "response_due")
print(len(notice_ids))
print(notice_ids[0])
print(notice_ids[-1])
print(notice_ids[1:3])
print(required_fields[0])
Four identifiers in a list, three field names in a tuple. Positions in notice_ids run: 0 is GSA-2026-0731, 1 is DLA-2026-0088, 2 is SPE-2026-0412, 3 is VA-2026-0155.
required_fields is a tuple because it is a fixed contract, not a working list. Nothing in the program should ever append to it, and writing it as a tuple makes an accidental append raise an AttributeError at the moment it happens rather than quietly changing what "required" means.
Inspect the indexes and slice boundary
One sequence, two index directions, one half-open slice
| Index / from end | Value | Slice |
|---|---|---|
| 0 / -4 | GSA-2026-0731 | Before start |
| 1 / -3 | DLA-2026-0088 | Included [1:3] Start 1 included |
| 2 / -2 | SPE-2026-0412 | |
| 3 / -1 | VA-2026-0155 | End 3 excluded |
Each row is one slot. The paired indexes name the same slot; the bracket spans only the included rows.
The tuple boundary is its slots
List- notice_ids can grow or have a slot replaced.
Tuple- required_fields has fixed slots; assigning a slot or calling append fails.
Nested values- A tuple can hold a mutable object, such as a list. That contained object can still change.
The worked required_fields tuple contains strings, not a nested list.
Predict the output
Predict all five printed lines, including how the slice is displayed.
Check your prediction
4
GSA-2026-0731
VA-2026-0155
['DLA-2026-0088', 'SPE-2026-0412']
notice_id
The slice prints as a list, brackets and all, because a slice of a list is a list. It contains positions 1 and 2 and stops before position 3, which is the half-open convention. If you expected three items, that is the single most common slicing mistake and it is worth re-reading the rule once more.
Modify the code
Add the line notice_ids.append("HHS-2026-0203") immediately before the print(len(notice_ids)) line. Predict which of the five printed lines change.
What changes, and why
Two lines change. The count becomes 5, and notice_ids[-1] becomes HHS-2026-0203, because -1 always means "whatever is last right now".
The slice notice_ids[1:3] is unchanged, because it names fixed positions and the insertion happened at the end. Had the new identifier been inserted at position 0 instead, the slice would have returned two different identifiers while looking exactly the same in the source. Position-based reads are only stable while the positions are.
Debug the bug
An assistant was asked to add an identifier to a shortlist and report the new count. It produced this and said it prints 4.
shortlist = ["GSA-2026-0731", "DLA-2026-0088", "SPE-2026-0412"]
shortlist = shortlist.append("VA-2026-0155")
print(len(shortlist))
What's actually wrong
It raises TypeError: object of type 'NoneType' has no len().
append modifies the list in place and returns None. The assignment therefore throws away the list and binds shortlist to None, so the next line asks for the length of nothing.
The fix is to drop the assignment entirely:
shortlist.append("VA-2026-0155")
print(len(shortlist))
The general rule is worth carrying: a method that changes an object in place returns None, and a method that produces a new value returns that value. sorted(items) returns a new sorted list; items.sort() sorts in place and returns None. Confusing the two pairs is one of the most common sources of a name that mysteriously holds None.
Try it yourself
Extend a list of notice identifiers, then inspect it two ways.
Loading this exercise…
Practical challenge (optional)
Optional: create a second name for the same list, backup = notice_ids, then append to notice_ids and print len(backup). Explain in one sentence why the backup grew. Then find the one-character change to the assignment that makes backup an independent copy, and confirm it. Aliasing is invisible in a code review and obvious the moment you test for it.
Sign in to track your progress on this exercise.
AI collaboration
Checkpoint
Given codes = ["a", "b", "c", "d", "e"], give the value of each expression, or name the error:
codes[2]codes[1:4]codes[-2]codes[5]len(codes[2:])
Answers
"c". Positions start at zero.["b", "c", "d"]. Positions 1, 2 and 3; position 4 is excluded."d". Second from the end.IndexError: list index out of range. The last valid position is 4, because there are five items.3. The slice from position 2 onward holds"c","d", and"e".
Sign in to track your progress on this exercise.
Summary and next step
Lists are ordered and changeable, tuples are ordered and fixed, positions start at zero, slices exclude their end, and in-place methods return None. Next: dictionaries, which let a record be addressed by field name instead of by position, and sets, which answer membership questions quickly.