Skip to content

Lists and Tuples

A list is Python’s workhorse container. It holds an ordered sequence of any objects and supports in-place mutation.

fruits = ['apple', 'banana', 'cherry']
fruits.append('date') # add to end
fruits.insert(1, 'avocado') # insert before index 1
fruits.remove('banana') # remove first occurrence
print(fruits) # ['apple', 'avocado', 'cherry', 'date']
MethodDescription
append(x)Add x to the end
insert(i, x)Insert x before index i
remove(x)Remove first occurrence of x
pop(i=-1)Remove and return item at index i
sort(key=None, reverse=False)Sort in place
reverse()Reverse in place
index(x)Return index of first occurrence of x
count(x)Count occurrences of x

Sorting: in place vs a new list, and stability

Section titled “Sorting: in place vs a new list, and stability”

list.sort() sorts in place and returns None. Pass a key function to sort by an arbitrary criterion.

numbers = [5, 2, 8, 1, 9, 3]
numbers.sort()
print(numbers) # [1, 2, 3, 5, 8, 9]
words = ['banana', 'apple', 'cherry']
words.sort(key=len)
print(words) # ['apple', 'banana', 'cherry']

Use sorted(iterable) instead of .sort() when you need a new list and want to leave the original unchanged — sorted() always returns the list, .sort() never does. Chaining off .sort() (x = lst.sort(), or lst.sort()[:3]) is a real bug: x/the slice target is None.

Both .sort() and sorted() are stable: elements that compare equal under key keep their original relative order. This matters for multi-key sorting — sort by the least significant key first, then by the most significant:

people = [('carol', 30), ('alice', 30), ('dave', 25), ('bob', 25)]
people.sort(key=lambda p: p[1])
print(people) # [('dave', 25), ('bob', 25), ('carol', 30), ('alice', 30)]

Stability alone only guarantees the original order survives within an age group — it does not alphabetize the names. For a true multi-key sort, put every key in one tuple:

people.sort(key=lambda p: (p[1], p[0]))
print(people) # [('bob', 25), ('dave', 25), ('alice', 30), ('carol', 30)]

b = a does not create a second list — it binds a second name to the same object. Mutating through either name is visible through both:

a = [1, 2, 3]
b = a
b.append(4)
print(a) # [1, 2, 3, 4] — a changed too, even though only b was touched

a[:] (or list(a), or a.copy()) makes a shallow copy: a new outer list, but every element is the same object it was before. If the elements are themselves mutable, they are still shared:

a = [1, [2, 3]]
c = a[:]
c[1].append(9)
print(a) # [1, [2, 3, 9]] — the inner list is shared, so a saw the mutation too

Only copy.deepcopy() recursively duplicates nested mutable structure so nothing is shared:

import copy
a = [1, [2, 3]]
d = copy.deepcopy(a)
d[1].append(100)
print(a) # [1, [2, 3]] — untouched; d's inner list is an independent object

[[0] * 3] * 3 multiplies a list object, not the values inside it — every row of the resulting grid is the same inner list, aliased three times:

grid = [[0] * 3] * 3
grid[0][0] = 1
print(grid) # [[1, 0, 0], [1, 0, 0], [1, 0, 0]] — every row "changed"

The fix is a comprehension, which evaluates [0] * 3 freshly on every iteration:

grid = [[0] * 3 for _ in range(3)]
grid[0][0] = 1
print(grid) # [[1, 0, 0], [0, 0, 0], [0, 0, 0]]

In-place mutation vs rebinding: += on a list vs a tuple

Section titled “In-place mutation vs rebinding: += on a list vs a tuple”

+= looks like one operator, but it does something different depending on the type on the left. On a list, += calls __iadd__, which extends the list in place — every alias sees the change. On a tuple, there is no in-place __iadd__; Python falls back to t = t + (...), which builds a brand-new tuple and rebinds the name — aliases still point at the old tuple:

la = [1, 2]
lb = la
la += [3]
print(la, lb) # [1, 2, 3] [1, 2, 3] — same object, both names see it
ta = (1, 2)
tb = ta
ta += (3,)
print(ta, tb) # (1, 2, 3) (1, 2) — ta now points at a new tuple; tb is unaffected

x in some_list checks elements one at a time — O(n) in the size of the list. x in some_set hashes x and looks up a bucket — O(1) on average, regardless of how large the set is. Both give the same boolean answer; only the cost differs, and it only shows up as the collection grows:

names_list = ['alice', 'bob', 'carol']
names_set = {'alice', 'bob', 'carol'}
print('bob' in names_list, 'bob' in names_set) # True True — same answer, different cost

If a function does repeated in checks against a list that could grow large, converting it to a set (or frozenset, if it must not change) is a one-line performance fix.

A tuple is like a list but cannot be modified after creation. Use tuples to represent fixed records where position carries meaning.

rgb = (255, 128, 0) # an orange colour
point = (3.0, 4.0) # a 2-D coordinate
row = (1, 'Alice', 42) # a database row

“Immutable” only means a tuple’s own slots can’t be reassigned — it says nothing about whether an element inside the tuple is itself mutable:

t = (1, [2, 3])
t[0] = 99 # TypeError: 'tuple' object does not support item assignment
t[1].append(4) # allowed — the list inside the tuple can still change
print(t) # (1, [2, 3, 4])

That mutable element also has a consequence for hashing: a tuple is only hashable — and only usable as a dict key or set member — if every element it contains is hashable.

d = {t: 'value'} # TypeError: cannot use 'tuple' as a dict key (unhashable type: 'list')

A tuple of only hashable elements works fine as a key:

point = (1, 2)
by_point = {point: 'ok'}

Python lets you unpack a tuple (or any iterable) directly into variables. The number of variables must match the length — unless you use * to capture the rest.

coords = (10.5, 20.3, 30.1)
x, y, z = coords
print(x, y, z) # 10.5 20.3 30.1
first, *rest = [1, 2, 3, 4, 5]
print(first) # 1
print(rest) # [2, 3, 4, 5]

Accessing tuple fields by index — or by name

Section titled “Accessing tuple fields by index — or by name”

Without a name, tuple fields are accessed by integer index. Assign constants for readability:

RGB = (255, 128, 0)
RED = 0
GREEN = 1
BLUE = 2
print(RGB[RED]) # 255
print(RGB[GREEN]) # 128
print(RGB[BLUE]) # 0

typing.NamedTuple gives the same tuple (index access, unpacking, equality, hashability) plus named attribute access, in about three lines. A @dataclass looks similar but is a different kind of object — mutable by default, and not usable as a dict key unless you opt in:

from typing import NamedTuple
from dataclasses import dataclass
class PointNT(NamedTuple):
x: int
y: int
@dataclass
class PointDC:
x: int
y: int
pnt = PointNT(1, 2)
pnt.x = 99 # AttributeError: can't set attribute — still a tuple, still immutable
pdc = PointDC(1, 2)
pdc.x = 99 # allowed — a dataclass instance is a plain, mutable object

Reach for NamedTuple when the value is a small, fixed, hashable record (safe as a dict key, safe to pass around without worrying who mutates it). Reach for @dataclass when you want a mutable object with class-like behavior (methods, inheritance, field(default_factory=...)).

A list’s insert(0, x) and pop(0) are O(n) — every other element has to shift. collections.deque is a double-ended queue with O(1) append and pop at both ends, at the cost of O(n) random access by index (no slicing):

from collections import deque
dq = deque([1, 2, 3])
dq.appendleft(0)
dq.append(4)
print(dq) # deque([0, 1, 2, 3, 4])

Use deque for queues, sliding windows, and undo/history buffers; keep list when you mostly index, slice, or append at the end only.

# --- list mutation and sorting ---
fruits = ['apple', 'banana', 'cherry']
fruits.append('date')
fruits.insert(1, 'avocado')
print('after insert:', fruits)
fruits.remove('banana')
print('after remove:', fruits)
numbers = [5, 2, 8, 1, 9, 3]
numbers.sort()
print('sorted:', numbers)
words = ['banana', 'apple', 'cherry']
words.sort(key=len)
print('by length:', words)
# --- tuple unpacking ---
coords = (10.5, 20.3, 30.1)
x, y, z = coords
print('unpacked:', x, y, z)
first, *rest = [1, 2, 3, 4, 5]
print('first:', first)
print('rest:', rest)
# --- named fields via index constants ---
RGB = (255, 128, 0)
print('red:', RGB[0], 'green:', RGB[1], 'blue:', RGB[2])

The playground below is where the depth topics above actually run — aliasing vs shallow vs deep copy, the [[0]*3]*3 trap, .sort() vs sorted() with stability, += on a list vs a tuple, a tuple’s mutable element blocking hashing, NamedTuple vs dataclass, and deque:

import copy
# --- aliasing vs shallow copy vs deep copy ---
a = [1, [2, 3]]
b = a # alias: same object
c = a[:] # shallow copy: new outer list, same inner list
d = copy.deepcopy(a) # deep copy: fully independent
b.append(4)
c[1].append(99)
d[1].append(100)
print('a:', a)
print('b:', b)
print('c:', c)
print('d:', d)
# --- [[0]*3]*3 shares rows ---
grid = [[0] * 3] * 3
grid[0][0] = 1
print('broken grid:', grid)
fixed = [[0] * 3 for _ in range(3)]
fixed[0][0] = 1
print('fixed grid:', fixed)
# --- list.sort() returns None; sorted() returns a new list ---
nums = [3, 1, 2]
result = nums.sort()
print('sort() return value:', result)
print('nums after sort():', nums)
nums2 = [3, 1, 2]
nums2_sorted = sorted(nums2)
print('sorted() return value:', nums2_sorted)
print('nums2 unchanged:', nums2)
# --- stability + multi-key sort ---
people = [('carol', 30), ('alice', 30), ('dave', 25), ('bob', 25)]
people.sort(key=lambda p: p[1])
print('stable by age:', people)
people.sort(key=lambda p: (p[1], p[0]))
print('multi-key age,name:', people)
# --- membership: list O(n) vs set O(1), same result different cost ---
names_list = ['alice', 'bob', 'carol']
names_set = {'alice', 'bob', 'carol'}
print('in list:', 'bob' in names_list, ' in set:', 'bob' in names_set)
# --- += mutates a list in place (shared); += on a tuple rebinds (not shared) ---
la = [1, 2]
lb = la
la += [3]
print('list +=:', la, lb)
ta = (1, 2)
tb = ta
ta += (3,)
print('tuple +=:', ta, tb)
# --- tuple is immutable but its mutable elements are not ---
t = (1, [2, 3])
try:
t[0] = 99
except TypeError as e:
print(type(e).__name__ + ':', e)
t[1].append(4)
print('tuple with mutated inner list:', t)
# --- that same tuple can't be a dict key because it contains a list ---
try:
d2 = {t: 'value'}
except TypeError as e:
print(type(e).__name__ + ':', e)
frozen_point = (1, 2)
d3 = {frozen_point: 'ok'}
print('hashable tuple as key:', d3)
# --- NamedTuple vs dataclass, three lines each ---
from typing import NamedTuple
from dataclasses import dataclass
class PointNT(NamedTuple):
x: int
y: int
@dataclass
class PointDC:
x: int
y: int
pnt = PointNT(1, 2)
pdc = PointDC(1, 2)
print('namedtuple:', pnt, pnt.x, pnt[0], pnt == PointNT(1, 2))
print('dataclass:', pdc, pdc.x, pdc == PointDC(1, 2))
try:
pnt.x = 99
except AttributeError as e:
print(type(e).__name__ + ':', e)
pdc.x = 99
print('dataclass is mutable:', pdc)
# --- deque: O(1) at both ends vs list's O(n) insert(0, ...) ---
from collections import deque
dq = deque([1, 2, 3])
dq.appendleft(0)
dq.append(4)
print('deque:', dq)
lst = [1, 2, 3]
lst.insert(0, 0)
print('list insert(0, ...):', lst)
flowchart LR
  subgraph Names
    a["a"]
    b["b = a"]
    c["c = a[:]"]
    d["d = deepcopy(a)"]
  end
  subgraph Objects["heap objects"]
    OUTER1["list #1 [1, ref, 4]"]
    OUTER2["list #2 (shallow copy) [1, ref]"]
    INNER["inner list #3 [2, 3, 99]"]
    OUTER3["list #4 (deep copy) [1, ref]"]
    INNER2["inner list #5 [2, 3, 100]"]
  end
  a --> OUTER1
  b --> OUTER1
  c --> OUTER2
  OUTER1 --> INNER
  OUTER2 --> INNER
  d --> OUTER3
  OUTER3 --> INNER2
a and b share one list object; c is a new outer list that still shares the inner list; only d (deepcopy) is fully independent
What you getBenefitCost
list (mutable)append/sort/mutate in place, no copying to change itevery alias shares the mutation — easy to change something you didn’t mean to
tuple (immutable)safe to hash, share, and use as a dict key or set memberfixed size and order; “immutable” only protects the tuple’s own slots, not a mutable element inside it
list.sort() (in place)no new list allocatedreturns None — chaining off it (.sort()[:3]) is a TypeError
sorted() (new list)original is untouched, safe to chainalways allocates a new list, even for a quick peek
dequeO(1) append/pop at both endsO(n) random access by index, no slicing
  • “b = a copies the list” — fact: it binds a second name to the same object; mutating through b also changes what a sees.
  • “a[:] makes a fully independent copy” — fact: a[:] is a shallow copy — nested mutable elements (like an inner list) are still shared.
  • “list.sort() returns the sorted list” — fact: .sort() always returns None; only sorted() returns a new list.
  • “A tuple is always safe as a dict key” — fact: only if every element inside it is hashable — a tuple holding a list raises TypeError: unhashable type: 'list'.
  • “+= behaves the same on a list and a tuple” — fact: on a list it mutates in place (aliases see it); on a tuple it rebuilds a new tuple and rebinds the name (aliases do not see it).
  • Does a function that receives a list mutate it in place and return it? Check every call site for whether the caller expects the original list to survive untouched.
  • Does the code chain directly off .sort() (.sort()[:3], x = lst.sort())? .sort() always returns None — that line is a bug.
  • Is a 2D grid built with [[default] * cols] * rows? Every row is the same list object — look for the comprehension form ([[default] * cols for _ in range(rows)]) instead.
  • Does += appear on something that might be a tuple, in code that assumes the original object was mutated in place? Tuples rebind instead of mutating.
  • Is a tuple used as a dict key or set element without checking that every element inside it is itself hashable?

🐛 Spot the bug

def top_three(scores):
return scores.sort(reverse=True)[:3]
print(top_three([42, 7, 99, 3, 56]))
Show the answer

list.sort() sorts in place and always returns None — it is not sorted(). So scores.sort(reverse=True) returns None, and None[:3] raises TypeError: 'NoneType' object is not subscriptable. Verified on Python 3.14: the traceback ends with exactly that message. The fix is either return sorted(scores, reverse=True)[:3] (build a new list and slice it), or two statements: scores.sort(reverse=True); return scores[:3].

What does this print? a = [1, 2, 3] b = a b.append(4) print(a)
What does this print? a = [1, [2, 3]] c = a[:] c[1].append(9) print(a)
What does result = [3, 1, 2].sort() bind result to?
Why does grid = [[0] * 3] * 3; grid[0][0] = 1 set the first element of every row, not just row 0?
What does this print? t = (1, 2) u = t t += (3,) print(u)
A function receives a list, needs the top 3 highest scores, and must not change the caller's list. Which implementation is correct?