Lists and Tuples
Lists — ordered, mutable sequences
Section titled “Lists — ordered, mutable sequences”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 endfruits.insert(1, 'avocado') # insert before index 1fruits.remove('banana') # remove first occurrenceprint(fruits) # ['apple', 'avocado', 'cherry', 'date']Common list methods
Section titled “Common list methods”| Method | Description |
|---|---|
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)]Aliasing: assignment never copies
Section titled “Aliasing: assignment never copies”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 = ab.append(4)print(a) # [1, 2, 3, 4] — a changed too, even though only b was toucheda[:] (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 tooOnly copy.deepcopy() recursively duplicates nested mutable structure so nothing is shared:
import copya = [1, [2, 3]]d = copy.deepcopy(a)d[1].append(100)print(a) # [1, [2, 3]] — untouched; d's inner list is an independent objectThe [[0] * n] * m trap
Section titled “The [[0] * n] * m trap”[[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] * 3grid[0][0] = 1print(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] = 1print(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 = lala += [3]print(la, lb) # [1, 2, 3] [1, 2, 3] — same object, both names see it
ta = (1, 2)tb = tata += (3,)print(ta, tb) # (1, 2, 3) (1, 2) — ta now points at a new tuple; tb is unaffectedMembership testing: in on a list vs a set
Section titled “Membership testing: in on a list vs a set”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 costIf 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.
Tuples — ordered, immutable sequences
Section titled “Tuples — ordered, immutable sequences”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 colourpoint = (3.0, 4.0) # a 2-D coordinaterow = (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 assignmentt[1].append(4) # allowed — the list inside the tuple can still changeprint(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'}Tuple unpacking
Section titled “Tuple unpacking”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 = coordsprint(x, y, z) # 10.5 20.3 30.1
first, *rest = [1, 2, 3, 4, 5]print(first) # 1print(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 = 0GREEN = 1BLUE = 2
print(RGB[RED]) # 255print(RGB[GREEN]) # 128print(RGB[BLUE]) # 0typing.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 NamedTuplefrom dataclasses import dataclass
class PointNT(NamedTuple): x: int y: int
@dataclassclass PointDC: x: int y: intpnt = PointNT(1, 2)pnt.x = 99 # AttributeError: can't set attribute — still a tuple, still immutablepdc = PointDC(1, 2)pdc.x = 99 # allowed — a dataclass instance is a plain, mutable objectReach 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=...)).
Beyond list: deque for both ends
Section titled “Beyond list: deque for both ends”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 dequedq = 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.
Full runnable demo
Section titled “Full runnable demo”# --- 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 = coordsprint('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])Loading Python runtime (first run only)…
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 objectc = a[:] # shallow copy: new outer list, same inner listd = 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] * 3grid[0][0] = 1print('broken grid:', grid)
fixed = [[0] * 3 for _ in range(3)]fixed[0][0] = 1print('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 = lala += [3]print('list +=:', la, lb)
ta = (1, 2)tb = tata += (3,)print('tuple +=:', ta, tb)
# --- tuple is immutable but its mutable elements are not ---t = (1, [2, 3])try: t[0] = 99except 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 NamedTuplefrom dataclasses import dataclass
class PointNT(NamedTuple): x: int y: int
@dataclassclass 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 = 99except AttributeError as e: print(type(e).__name__ + ':', e)pdc.x = 99print('dataclass is mutable:', pdc)
# --- deque: O(1) at both ends vs list's O(n) insert(0, ...) ---from collections import dequedq = 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)Loading Python runtime (first run only)…
Mental Model
Section titled “Mental Model”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 Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
list (mutable) | append/sort/mutate in place, no copying to change it | every 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 member | fixed size and order; “immutable” only protects the tuple’s own slots, not a mutable element inside it |
list.sort() (in place) | no new list allocated | returns None — chaining off it (.sort()[:3]) is a TypeError |
sorted() (new list) | original is untouched, safe to chain | always allocates a new list, even for a quick peek |
deque | O(1) append/pop at both ends | O(n) random access by index, no slicing |
Common Misconceptions
Section titled “Common Misconceptions”- “
b = acopies the list” — fact: it binds a second name to the same object; mutating throughbalso changes whatasees. - “
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 returnsNone; onlysorted()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).
Reading AI-generated code
Section titled “Reading AI-generated code”- Does a function that receives a list mutate it in place and
returnit? 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 returnsNone— 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].