Lists, Tuples and Slicing

Article · 11:00

A list is an ordered, mutable sequence; a tuple is the same thing without the mutation, so the choice between them is a statement about whether the contents are meant to change. With slicing, unpacking, and the copy that is not deep.

This lesson has 16 runnable examples. Press Try yourself! under any of them to open it in the editor.

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

PY Example 1 16 lines
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:

PY Example 2 10 lines
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:

PY Example 3 12 lines
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

PY Example 4 9 lines
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:

PY Example 5 8 lines
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:

PY Example 6 7 lines
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:

PY Example 7 9 lines
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

PY Example 8 7 lines
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.

PY Example 9 3 lines
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:

PY Example 10 10 lines
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:

PY Example 11 11 lines
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

PY Example 12 15 lines
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:

PY Example 13 7 lines
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

PY Example 14 51 lines
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
PY Example 15 5 lines
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

PY Example 16 7 lines
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.