Lists, Tuples and Slicing
A list is a mutable sequence. A tuple is an immutable one. That difference decides which operations exist, which can be dictionary keys, and which can be safely shared - and slicing is the syntax that makes working with both pleasant.
Lists: the operations, and the ones that return None
items = ["b", "a", "c"]
items.append("d") # add one at the end
items.extend(["e", "f"]) # add several
items.insert(0, "start") # insert at a position - O(n), so avoid in a loop
print(items)
# ['start', 'b', 'a', 'c', 'd', 'e', 'f']
print(items.pop()) # 'f' - removes and returns the last
print(items.pop(0)) # 'start' - and from the front, also O(n)
items.remove("a") # removes the first match; ValueError if absent
print(items) # ['b', 'c', 'd', 'e']
print(items.index("d")) # 2
print(items.count("b")) # 1
print("c" in items) # True - a linear scan
Open this example in the editor and change it. Nothing you type is saved.
The distinction that catches everyone:
numbers = [3, 1, 2]
result = numbers.sort() # sorts IN PLACE and returns None
print(result) # None
print(numbers) # [1, 2, 3]
numbers = [3, 1, 2]
result = sorted(numbers) # returns a NEW list
print(result) # [1, 2, 3]
print(numbers) # [3, 1, 2] - untouched
Open this example in the editor and change it. Nothing you type is saved.
sort() rearranges in place and returns None. sorted() returns a new list and leaves the original alone. Assigning the result of sort() is a common bug, and it is silent until you use the None.
The same applies to reverse() versus reversed(), and to append, extend, insert, remove and clear - all of which return None by convention, precisely so that chaining them looks wrong.
Sorting by a computed key is the thing you will do most:
people = [("Aisha", 29), ("Noshad", 35), ("Sam", 29)]
print(sorted(people, key=lambda p: p[1]))
# [('Aisha', 29), ('Sam', 29), ('Noshad', 35)]
# Descending, and by two keys at once - age down, then name up.
print(sorted(people, key=lambda p: (-p[1], p[0])))
# [('Noshad', 35), ('Aisha', 29), ('Sam', 29)]
from operator import itemgetter
print(sorted(people, key=itemgetter(1, 0)))
# [('Aisha', 29), ('Sam', 29), ('Noshad', 35)]
Open this example in the editor and change it. Nothing you type is saved.
Python's sort is stable: equal keys keep their original relative order. That is a guarantee, not an accident, and it means you can sort by a secondary key first and a primary key second to get a multi-level sort without writing a tuple key.
Slicing
letters = ["a", "b", "c", "d", "e", "f"]
print(letters[1:4]) # ['b', 'c', 'd'] - start inclusive, stop not
print(letters[:3]) # ['a', 'b', 'c']
print(letters[3:]) # ['d', 'e', 'f']
print(letters[-2:]) # ['e', 'f'] - last two
print(letters[::2]) # ['a', 'c', 'e'] - every second
print(letters[::-1]) # ['f', ... 'a'] - reversed
print(letters[1:5:2]) # ['b', 'd'] - start, stop, step
Open this example in the editor and change it. Nothing you type is saved.
Two properties make slicing safe in a way indexing is not:
letters = ["a", "b", "c", "d", "e", "f"]
print(letters[10:20]) # [] - no IndexError, just empty
try:
print(letters[10])
except IndexError as error:
print(type(error).__name__) # IndexError
Open this example in the editor and change it. Nothing you type is saved.
A slice out of range gives you an empty list; an index out of range raises. That is why line[:80] is a safe truncation for a line of any length.
A slice of a list is a new list, so this is a shallow copy:
original = [[1, 2], [3, 4]]
shallow = original[:]
shallow.append([5, 6])
print(len(original), len(shallow)) # 2 3 - the outer list is independent
shallow[0].append(99)
print(original[0]) # [1, 2, 99] - the inner list is shared
Open this example in the editor and change it. Nothing you type is saved.
Slices assign, too, and the slice does not have to be the same length as what replaces it:
row = [1, 2, 3, 4, 5]
row[1:3] = ["x", "y", "z"]
print(row) # [1, 'x', 'y', 'z', 4, 5]
row[::2] = [0, 0, 0] # extended slices DO need matching lengths
print(row) # [0, 'x', 0, 'z', 0, 5] - indices 0, 2 and 4
del row[1:3]
print(row) # [0, 'z', 0, 5]
Open this example in the editor and change it. Nothing you type is saved.
Tuples, and why immutability is useful
point = (3, 4)
print(point[0]) # 3
# point[0] = 5 # TypeError: tuples do not support item assignment
single = (3,) # the comma makes the tuple, not the brackets
not_a_tuple = (3)
print(type(single), type(not_a_tuple)) # <class 'tuple'> <class 'int'>
Open this example in the editor and change it. Nothing you type is saved.
Four things immutability buys you:
A tuple can be a dictionary key, a list cannot.
distances = {("London", "Leeds"): 196, ("Leeds", "York"): 25}
print(distances[("London", "Leeds")]) # 196
# {[1, 2]: "x"} # TypeError: unhashable type: 'list'
Open this example in the editor and change it. Nothing you type is saved.
It can be shared without defensive copying. If nobody can change it, nobody needs their own copy.
It signals intent. A tuple says "these belong together and this is the shape", a list says "these are the same kind of thing and there may be any number". (x, y) and [1, 2, 3, 4] are different ideas.
It unpacks cleanly - and unpacking works on any sequence:
a, b, c = [1, 2, 3]
first, *middle, last = [1, 2, 3, 4, 5]
print(first, middle, last) # 1 [2, 3, 4] 5
x, y = 1, 2
x, y = y, x # the right side is built first
print(x, y) # 2 1
for index, (name, age) in enumerate([("Aisha", 29), ("Sam", 29)]):
print(index, name, age)
Open this example in the editor and change it. Nothing you type is saved.
A named tuple gives the positions names without giving up immutability:
from typing import NamedTuple
class Point(NamedTuple):
x: float
y: float
label: str = ""
p = Point(3, 4, "origin-ish")
print(p.x, p[0]) # 3 3 - works both ways
print(p._replace(x=10)) # Point(x=10, y=4, label='origin-ish')
print(tuple(p)) # (3, 4, 'origin-ish')
Open this example in the editor and change it. Nothing you type is saved.
_replace returns a new instance; the underscore means "not part of the field namespace", not "private".
The copy that is not deep
import copy
grid = [[0, 0], [0, 0]]
wrong = grid * 2 # the SAME inner list, twice more
print(wrong) # [[0, 0], [0, 0], [0, 0], [0, 0]]
wrong[0][0] = 9
print(wrong) # every row that shares that object changed
# [[9, 0], [0, 0], [9, 0], [0, 0]]
board = [[0, 0], [0, 0]]
shallow = copy.copy(board) # new outer list, same inner lists
deep = copy.deepcopy(board) # new all the way down
board[0][0] = 7
print(shallow[0][0], deep[0][0]) # 7 0
Open this example in the editor and change it. Nothing you type is saved.
The * 2 trap has a famous cousin:
rows = [[0] * 3] * 2 # ONE row, referenced twice
rows[0][0] = 1
print(rows) # [[1, 0, 0], [1, 0, 0]]
rows = [[0] * 3 for _ in range(2)] # a new row each time
rows[0][0] = 1
print(rows) # [[1, 0, 0], [0, 0, 0]]
Open this example in the editor and change it. Nothing you type is saved.
[0] * 3 is fine, because integers are immutable and sharing them is harmless. [row] * 2 is not, because the row is not.
A worked example
from typing import NamedTuple
class Reading(NamedTuple):
sensor: str
celsius: float
minute: int
READINGS = [
Reading("kitchen", 21.5, 0), Reading("loft", 17.2, 0),
Reading("kitchen", 22.1, 5), Reading("loft", 16.9, 5),
Reading("kitchen", 23.8, 10), Reading("loft", 17.0, 10),
Reading("kitchen", 22.0, 15), Reading("loft", 18.4, 15),
]
# 1. Sorted copies, leaving READINGS alone - and a stable two-level sort.
by_temperature = sorted(READINGS, key=lambda r: r.celsius, reverse=True)
print(by_temperature[0])
# Reading(sensor='kitchen', celsius=23.8, minute=10)
by_sensor_then_time = sorted(READINGS, key=lambda r: (r.sensor, r.minute))
print([r.minute for r in by_sensor_then_time[:4]]) # [0, 5, 10, 15]
# 2. Slicing as a window: a three-reading moving average.
kitchen = [r.celsius for r in READINGS if r.sensor == "kitchen"]
print(kitchen) # [21.5, 22.1, 23.8, 22.0]
windows = [kitchen[i:i + 3] for i in range(len(kitchen) - 2)]
print(windows) # [[21.5, 22.1, 23.8], [22.1, 23.8, 22.0]]
print([round(sum(w) / len(w), 2) for w in windows]) # [22.47, 22.63]
# 3. Unpacking, where the shape is known.
first, *rest = kitchen
print(first, rest) # 21.5 [22.1, 23.8, 22.0]
coldest, *_, warmest = sorted(kitchen)
print(coldest, warmest) # 21.5 23.8
# 4. A tuple as a dictionary key, which a list could not be.
index = {(r.sensor, r.minute): r.celsius for r in READINGS}
print(index[("loft", 10)]) # 17.0
# 5. The grid trap, avoided.
hours, sensors = 3, 2
grid = [[0.0] * hours for _ in range(sensors)]
grid[0][0] = 21.5
print(grid) # [[21.5, 0.0, 0.0], [0.0, 0.0, 0.0]]
# 6. And READINGS itself is untouched by any of it, because every
# operation above either sliced or built something new.
print(len(READINGS), READINGS[0].sensor) # 8 kitchen
Open this example in the editor and change it. Nothing you type is saved.
Step two is the one to steal. [xs[i:i + n] for i in range(len(xs) - n + 1)] is the sliding window, and it works because a slice is a new list and a slice past the end is simply shorter rather than an error.
When to use which
| Use | When |
|---|---|
list |
Any number of the same kind of thing, and it will change |
tuple |
A fixed shape, a dict key, or a value that must not change |
NamedTuple |
A tuple whose positions deserve names |
deque |
Adding and removing at both ends - O(1) instead of O(n) |
array |
Millions of numbers of one type, and memory matters |
set |
Membership tests and deduplication |
from collections import deque
queue = deque([1, 2, 3])
queue.appendleft(0) # O(1); list.insert(0, x) is O(n)
print(queue.popleft(), queue) # 0 deque([1, 2, 3])
Open this example in the editor and change it. Nothing you type is saved.
When it goes wrong
| Symptom | Cause |
|---|---|
A sorted list became None |
sort() returns None; use sorted() |
| Changing one row changed every row | [[0] * n] * m shares one inner list |
| An inner list changed after copying | A slice copy is shallow; use deepcopy |
TypeError: unhashable type: 'list' |
A list used as a dict key or set member |
(3) was not a tuple |
The comma makes it: (3,) |
IndexError on the last element |
len(xs) is one past the end; use xs[-1] |
| Removing items in a loop skipped some | Build a new list instead of mutating while iterating |
| Inserting at the front is slow | list.insert(0, x) is O(n); use deque |
A check you can run
rows = [[0] * 3] * 3
rows[0][0] = 1
print(rows)
rows = [[0] * 3 for _ in range(3)]
rows[0][0] = 1
print(rows)
Open this example in the editor and change it. Nothing you type is saved.
One of those prints three identical rows and the other prints one changed row. The characters differ by about ten. If you can explain which is which without running it, you understand references in Python - and if you cannot, this is the five seconds that will fix it permanently.