Why we need collections
So far you have worked with single values: one number, one piece of text. Real programs almost always deal with groups of values. The scores of every student in a class. The words in a sentence. The daily prices of a stock. You need a way to hold many values under one name and get at them one at a time.
A collection is a value that holds other values. Python’s most common collection is the list. This lesson covers lists, their close cousin the tuple, and the single most useful operation on both: slicing. It also covers one bug that trips up almost every beginner, called aliasing.
Lists: an ordered, changeable sequence
A list is an ordered group of values. “Ordered” means the values have positions: a first one, a second one, and so on, and that order stays put unless you change it. You write a list with square brackets [ ] and commas between the items.
scores = [90, 85, 72, 100]
print(scores) # -> [90, 85, 72, 100]
The values inside are called elements or items. A list can hold any type of value, and even a mix:
mixed = [1, "hello", 3.5, True]
An empty list has nothing between the brackets:
empty = []
How many items: len
len(x) gives the length of a collection: how many items it holds. It is a built-in function.
scores = [90, 85, 72, 100]
print(len(scores)) # -> 4
print(len([])) # -> 0
len on a list is a fast operation: Python stores the count, so it does not walk through the items to count them. In Big-O terms (a way of describing how the cost of an operation grows as the data gets larger, introduced in an earlier lesson) len is O(1) — constant time, independent of how big the list is.
Indexing: getting one item by position
Each item sits at a numbered position called its index. Python counts positions starting from 0, not 1. So the first item is at index 0, the second at index 1, and so on. You read an item by putting its index in square brackets after the list.
scores = [90, 85, 72, 100]
print(scores[0]) # -> 90
print(scores[1]) # -> 85
print(scores[3]) # -> 100
The last valid index is len - 1. Here the length is 4, so the highest index is 3. Asking for an index that does not exist is an error:
scores = [90, 85, 72, 100]
print(scores[4]) # IndexError: list index out of range
It helps to picture a list as a row of boxes. Each box holds a value, and the number under each box is its index.
graph LR
subgraph scores
A["90"]
B["85"]
C["72"]
D["100"]
end
i0["index 0"] --> A
i1["index 1"] --> B
i2["index 2"] --> C
i3["index 3"] --> D
Negative indexing
Python lets you count from the end using negative numbers. -1 is the last item, -2 the second to last, and so on. This saves you from computing len - 1 by hand.
scores = [90, 85, 72, 100]
print(scores[-1]) # -> 100
print(scores[-2]) # -> 72
Indexing by a known position is O(1): Python jumps straight to the item without scanning the others.
Changing an item
A list is mutable, which means you can change its contents after you create it. Assign a new value to a position:
scores = [90, 85, 72, 100]
scores[1] = 88
print(scores) # -> [90, 88, 72, 100]
Mutating methods: growing and shrinking a list
A method is a function that belongs to a value; you call it by writing the value, a dot, and the method name with parentheses, like scores.append(...). These methods change the list in place, meaning they modify the existing list rather than producing a new one.
append: add to the end
append(value) adds one item to the end of the list.
scores = [90, 85]
scores.append(72)
print(scores) # -> [90, 85, 72]
Appending is amortized O(1). “Amortized” means that although a single append can occasionally be slow (when Python grows the list’s underlying storage), the cost averaged over many appends is constant. In practice you can treat append as cheap.
pop: remove from the end (and hand it back)
pop() removes the last item and returns it, so you can use the removed value.
scores = [90, 85, 72]
last = scores.pop()
print(last) # -> 72
print(scores) # -> [90, 85]
Popping the last item is O(1). You can also pop by index, pop(0), but that is O(n) — see remove below for why removing from the front is slow.
insert: add at a specific position
insert(index, value) puts a value before the given index, shifting everything from that point rightward to make room.
scores = [90, 72, 100]
scores.insert(1, 85) # put 85 at index 1
print(scores) # -> [90, 85, 72, 100]
Because every item after the insertion point must move over by one, insert is O(n) in the worst case, where n is the length of the list. Inserting at the very end is the cheap case (that is what append does).
remove: delete the first matching value
remove(value) searches from the left for the first item equal to value and deletes it. Everything after the deleted item shifts left to close the gap.
colors = ["red", "green", "red", "blue"]
colors.remove("red")
print(colors) # -> ['green', 'red', 'blue']
Removing an item is O(n): Python may scan the list to find the value, then shift the remaining items down. If the value is not present, remove raises a ValueError.
Here is a summary of the costs. n is the number of items in the list.
| Operation | Example | Time |
|---|---|---|
| Index | x[3] | O(1) |
| Length | len(x) | O(1) |
| Append | x.append(v) | Amortized O(1) |
| Pop from end | x.pop() | O(1) |
| Insert | x.insert(i, v) | O(n) |
| Remove | x.remove(v) | O(n) |
| Membership test | v in x | O(n) |
Membership: is a value in the list?
The in operator asks whether a value appears anywhere in the list. It gives back True or False.
scores = [90, 85, 72]
print(85 in scores) # -> True
print(50 in scores) # -> False
To answer, Python compares against items one by one until it finds a match or reaches the end, so membership is O(n). (In a later lesson you will meet the set, which answers this same question in O(1) on average.)
Iterating with a for loop
Iterating means visiting each item in a collection, one at a time. The for loop is the standard tool. It reads almost like English: “for each item in this list, do the following.”
scores = [90, 85, 72]
for s in scores:
print(s)
# -> 90
# -> 85
# -> 72
s is a name you choose; on each pass of the loop it holds the next item. The indented line below the for is the body, the work done for each item.
If you need the index as well as the value, enumerate gives you both:
scores = [90, 85, 72]
for i, s in enumerate(scores):
print(i, s)
# -> 0 90
# -> 1 85
# -> 2 72
A full pass over a list of n items is O(n): you do a constant amount of work n times.
Nested lists
A list item can itself be a list. A nested list (a list inside a list) is how you represent a grid or a table: a list of rows, where each row is a list of values.
grid = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
]
print(grid[0]) # -> [1, 2, 3] (the first row)
print(grid[0][2]) # -> 3 (row 0, column 2)
print(grid[1][0]) # -> 4 (row 1, column 0)
grid[1][0] reads left to right: first grid[1] gives the row [4, 5, 6], then [0] gives its first item. To visit every cell, loop over rows and then over each row’s items:
grid = [[1, 2], [3, 4]]
for row in grid:
for value in row:
print(value)
# -> 1
# -> 2
# -> 3
# -> 4
Tuples: an ordered, unchangeable sequence
A tuple is like a list, but immutable: once created, you cannot change, add, or remove its items. You write a tuple with round parentheses ( ).
point = (3, 4)
print(point[0]) # -> 3
print(len(point)) # -> 2
Indexing, negative indexing, len, slicing, for, and in all work on tuples exactly as they do on lists. What does not work is any operation that changes contents:
point = (3, 4)
point[0] = 9 # TypeError: 'tuple' object does not support item assignment
Packing and unpacking
Packing is putting several values into one tuple. You can even omit the parentheses:
point = 3, 4 # packs into the tuple (3, 4)
print(point) # -> (3, 4)
Unpacking is the reverse: pulling a tuple’s items out into separate names in one line. The number of names must match the number of items.
point = (3, 4)
x, y = point
print(x) # -> 3
print(y) # -> 4
This is why swapping two values in Python needs no temporary variable: the right side packs into a tuple, then unpacks into the names on the left.
a, b = 1, 2
a, b = b, a
print(a, b) # -> 2 1
A note on writing a one-item tuple: the comma, not the parentheses, is what makes it a tuple. (5) is just the number 5; (5,) is a tuple holding 5.
print(type((5))) # -> <class 'int'>
print(type((5,))) # -> <class 'tuple'>
When to use a tuple instead of a list
Reach for a tuple when the group of values is fixed and their positions carry meaning: a 2D point (x, y), a date (year, month, day), a function returning two results. The immutability is a feature: it signals “this will not change” and protects against accidental edits. Because a tuple cannot change, it can also be used as a key in a dictionary (a structure you will meet later), which a list cannot.
Reach for a list when the collection will grow, shrink, or be reordered: items in a shopping cart, scores you keep appending, a queue of tasks.
Slicing in depth
A slice copies out a range of items into a new list (or new tuple, if you slice a tuple). The syntax is x[start:stop:step].
start: index to begin at (included).stop: index to end at (excluded — the item atstopis not taken).step: how far to move between picks (default 1).
The rule that stop is excluded is worth memorizing. x[1:3] gives you indices 1 and 2, not 3.
nums = [10, 20, 30, 40, 50]
print(nums[1:3]) # -> [20, 30]
print(nums[0:2]) # -> [10, 20]
Any of the three parts can be left out. Omitting start means “from the beginning”; omitting stop means “through the end.”
nums = [10, 20, 30, 40, 50]
print(nums[:2]) # -> [10, 20] (from the start)
print(nums[2:]) # -> [30, 40, 50] (through the end)
print(nums[:]) # -> [10, 20, 30, 40, 50] (a full copy)
The step picks every k-th item. A step of 2 takes every other item.
nums = [10, 20, 30, 40, 50]
print(nums[::2]) # -> [10, 30, 50]
A negative step walks backward, which is the idiomatic way to reverse a sequence:
nums = [10, 20, 30, 40, 50]
print(nums[::-1]) # -> [50, 40, 30, 20, 10]
Slice indices are forgiving: a stop past the end does not error, it just stops at the end.
nums = [10, 20, 30]
print(nums[1:99]) # -> [20, 30]
A slice of k items is O(k) in time and O(k) in space, because it builds a new list and copies k items into it.
The aliasing gotcha: two names, one list
This is the bug that catches nearly every beginner, so read it slowly. When you assign a list to a new name, Python does not make a copy. Both names point at the same list in memory. This sharing is called aliasing — two names that are aliases for one object.
a = [1, 2, 3]
b = a # b is NOT a copy; it is another name for the same list
b.append(4)
print(a) # -> [1, 2, 3, 4] <-- a changed too!
print(b) # -> [1, 2, 3, 4]
Because a and b are the same list, a change made through one name is visible through the other. In memory, both names point at a single list:
graph LR
a["a"] --> L["[1, 2, 3, 4]"]
b["b"] --> L
Both arrows point at one list. Assignment (b = a) copies the arrow, not the boxes it points to.
How to actually copy a list
When you want an independent copy that you can change without touching the original, make a shallow copy. Any of these work and are equivalent:
a = [1, 2, 3]
b = list(a) # build a new list from a's items
c = a[:] # a full slice is a copy
b.append(4)
print(a) # -> [1, 2, 3] (unchanged)
print(b) # -> [1, 2, 3, 4]
Now a and b point at different lists, so changing one leaves the other alone. Copying n items is O(n) in time and space.
The word “shallow” matters. A shallow copy duplicates the outer list but still shares any inner lists. If your list contains other lists, the inner ones are aliased:
a = [[1, 2], [3, 4]]
b = list(a) # shallow copy
b[0].append(99) # reaches into the shared inner list
print(a) # -> [[1, 2, 99], [3, 4]] <-- a's inner list changed too
For nested structures where you need a fully independent copy, Python’s standard library has copy.deepcopy, which duplicates every level. That is beyond this lesson; for flat lists of numbers or strings, list(a) or a[:] is all you need.
Common pitfalls
- Off-by-one on the stop index.
x[start:stop]excludesstop.x[0:len(x)]is the whole list, not one past the end. - Indexing starts at 0. The first item is
x[0]; the last isx[len(x) - 1]orx[-1].x[len(x)]is always out of range. b = ais not a copy. For lists it creates an alias. Uselist(a)ora[:]when you need an independent list.- A one-item tuple needs a trailing comma.
(5)is an int; write(5,). removedeletes only the first match and errors if the value is absent. Check withinfirst if you are unsure.- Choosing insert/remove at the front for big lists is O(n). If you repeatedly add and remove at the front, a list is the wrong tool; a later lesson covers
collections.deque, which does this in O(1).
Practice
- Given
nums = [5, 3, 8, 1, 9, 2], use slicing to print (a) the first three items, (b) the last two items, (c) the list reversed, and (d) every second item. - Write a
forloop that goes through a list of words and prints only the words longer than four characters. Uselenon each word. - Start with
original = [1, 2, 3]. Make a real copy calledchanged, append4tochanged, and print both lists to confirmoriginalwas not affected. Then try the same starting withchanged = original(an alias) and observe the difference.