5.1 List
Where an operation applies to sequences generally (indexing, slicing, len(),
sorted(), ...), it's introduced here on list first and simply reused, unchanged, when
str and tuple come up later in this chapter.
5.1.1 Indexing and Slicing
Why lists are indexed, and how they're stored — CPython implements list as a
dynamic array: one contiguous block of memory holding references (pointers) to the
actual objects, not the objects themselves (each slot is the same fixed size regardless
of what it points to — which is also why a list can freely mix types, tying back to
1.1.3's "everything is an object"). Because the references sit back-to-back in memory,
the interpreter can jump straight to index i with one arithmetic calculation
(base_address + i * pointer_size) — no walking through the list required. That's what
makes indexing O(1) (constant time), regardless of how long the list is.
Indexing (seq[i]) returns a single element; slicing (seq[start:stop:step]) returns a
new sequence of the same type. Both work identically on any sequence type (list,
tuple, str, range) — demonstrated here on list, reused as-is for str/tuple.
| Form | Meaning | Time complexity |
|---|---|---|
seq[i] |
element at index i (0-based) |
O(1) |
seq[-i] |
element counted from the end (-1 is the last item) |
O(1) |
seq[start:stop] |
elements from start up to, not including, stop |
O(k) — k = length of the result |
seq[start:stop:step] |
as above, skipping by step |
O(k) — k = length of the result |
seq[::-1] |
the whole sequence, reversed | O(n) |
Visualizing indices — picture a list of length n as boxes in a row. Positive
indices count from the left, starting at 0; negative indices count from the
right, starting at -1. Both label the same boxes, just from opposite ends:
0 1 2 3 4 5 6 7
+----+----+----+----+----+----+----+----+
| a | b | c | d | e | f | g | h |
+----+----+----+----+----+----+----+----+
-8 -7 -6 -5 -4 -3 -2 -1
Column 2 and column -6 are the same box ('c'). In general, for a sequence of
length n, negative index i refers to the same position as positive index n + i.
x = [10, 20, 30, 40, 50]
print(x[0], x[2], x[-1]) # 10 30 50 -> first, third, last
print(x[1:4]) # [20, 30, 40] -> stop index excluded
print(x[:3], x[3:]) # [10, 20, 30] [40, 50] -> omitted start/stop default to the ends
print(x[::2]) # [10, 30, 50] -> every 2nd element
print(x[::-1]) # [50, 40, 30, 20, 10] -> reversed
10 30 50 [20, 30, 40] [10, 20, 30] [40, 50] [10, 30, 50] [50, 40, 30, 20, 10]
If you only need the common cases (x[:3], x[::-1], x[i:]), skip ahead — the rest of
this subsection is a deep dive into exactly how negative indices and steps combine.
Understanding slicing direction — a slice always walks from start towards stop
in the direction step points. Convert every index (positive or negative) to its actual
position first (position = index if non-negative, else n + index), then:
step |
Direction | Non-empty result needs |
|---|---|---|
positive (default +1) |
left → right | position of start before (less than) position of stop |
| negative | right → left | position of start after (greater than) position of stop |
If that requirement isn't met, the slice is simply empty — Python never raises an error for a "backwards" slice.
Case 1 — positive step (left → right). Take x[-6:-2] on the 8-element list from
the diagram above. Converting to positions: -6 -> 8 + (-6) = 2, -2 -> 8 + (-2) = 6.
Since 2 < 6, start is positioned before stop, so the forward walk works:
x = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
print(x[-6:-2]) # ['c', 'd', 'e', 'f'] -> walks left to right, positions 2 up to (not including) 6
['c', 'd', 'e', 'f']
Common mistake — with negative indices, a larger magnitude means further left,
not further right. Comparing the digits 6 and 2, -6 can feel like it should come
"after" -2 — but a negative index's magnitude is its distance from the end, so
-6 (6 away from the end) sits to the left of -2 (only 2 away from the end). That's
exactly why x[-6:-2] above walks forward correctly, just like x[2:6] would.
Case 2 — negative step (right → left). Now walk backwards over the same two
positions: x[-2:-6:-1]. start = -2 is position 6, stop = -6 is position 2.
Since 6 > 2, start is positioned after stop, so the backward walk works:
print(x[-2:-6:-1]) # ['g', 'f', 'e', 'd'] -> walks right to left, positions 6 down to (not including) 2
['g', 'f', 'e', 'd']
Mismatch the step's sign with the start/stop order and the walk can never take a
single step — the result is just an empty sequence, silently:
print(x[2:6:-1]) # [] -> step says right-to-left, but position 2 is BEFORE position 6
print(x[6:2]) # [] -> step defaults to +1 (left-to-right), but position 6 is AFTER position 2
[] []
Default start/stop values — omitting start or stop doesn't always mean "0" or
"the end"; it depends on the direction step is walking:
start defaults to |
stop defaults to |
|
|---|---|---|
Forward (step positive/omitted) |
position 0 (the first item) |
position n (one past the last item — i.e. run all the way to the end) |
Backward (step negative) |
position n - 1 (the last item) |
run off the front of the sequence, including position 0 |
That last cell is the sharp edge: there is no explicit stop value that means "off the
front including index 0" — writing stop=0 excludes index 0, since stop is always
exclusive. To include the first element while walking backwards, stop must be omitted
entirely:
print(x[::-1]) # ['h', 'g', 'f', 'e', 'd', 'c', 'b', 'a'] -> stop omitted, includes index 0
print(x[len(x) - 1:0:-1]) # ['h', 'g', 'f', 'e', 'd', 'c', 'b'] -> stop=0 excludes index 0 ('a' is missing)
['h', 'g', 'f', 'e', 'd', 'c', 'b', 'a'] ['h', 'g', 'f', 'e', 'd', 'c', 'b']
Exercises — work these out on paper first (convert every index to a position), then
check in the scratch cell at the end of this chapter. Use
x = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] throughout.
- Predict
x[-7:-3]before running it. Which case is this (forward or backward)? - Predict
x[-3:-7:-1]. - Without running it, say whether
x[3:-3]is empty or not, and why. - Write a slice that produces
['h', 'g', 'f']using only negative indices. - Why does
x[-1:-8:-1]leave out'a', and how would you fix it so'a'is included? x[1:5:0]raises an error rather than returning an empty list — reason out why astepof0is a special case rather than just "an unusual direction."
Common mistake — an out-of-range index raises IndexError; an out-of-range
slice just clamps to whatever elements exist instead of raising:
x = [1, 2, 3]
print(x[10:20]) # [] -> clamps silently, no error
try:
print(x[10])
except IndexError as e:
print("IndexError:", e)
[] IndexError: list index out of range
Since list is mutable, a slice can be assigned to — replacing, growing or shrinking
the list in place, without changing its id() (2.3.1):
x = [1, 2, 3, 4, 5]
print(id(x))
x[1:3] = [20, 30, 40] # replaces 2 elements with 3 -> list grows
print(x, id(x)) # [1, 20, 30, 40, 4, 5] <- same id, same object
140316322520768 [1, 20, 30, 40, 4, 5] 140316322520768
seq[:] (a full slice) produces a shallow copy — a new list object with the same
top-level elements. It's how you detach a variable from the original without affecting
it, since plain = would just bind a second name to the same object (2.4):
a = [1, 2, 3]
b = a[:]
b.append(4)
print(a, b, a is b) # [1, 2, 3] [1, 2, 3, 4] False -> independent objects
[1, 2, 3] [1, 2, 3, 4] False
5.1.2 List Methods
Because the underlying array is contiguous (5.1.1), adding/removing at the end just
grows or shrinks the block by one slot — CPython even over-allocates extra capacity so
most append() calls don't need to resize at all, making it O(1) on average
(amortized). Adding/removing anywhere else means shifting every following element over
by one slot, which is O(n).
| Method | Effect | Mutates in place? | Time complexity |
|---|---|---|---|---|
| append(x) | add x to the end | Yes | O(1) amortized |
| extend(iterable) | append every item of iterable | Yes | O(k) — k = items added |
| insert(i, x) | insert x before index i | Yes | O(n) |
| remove(x) | remove the first occurrence of x (ValueError if absent) | Yes | O(n) |
| pop(i=-1) | remove and return the item at index i (default: last) | Yes | O(1) at the end, O(n) elsewhere |
| clear() | remove all items | Yes | O(n) |
| index(x) | index of the first occurrence of x | No | O(n) |
| count(x) | number of occurrences of x | No | O(n) |
| sort(key=None, reverse=False) | sort in place | Yes | O(n log n) |
| reverse() | reverse in place | Yes | O(n) |
| copy() | shallow copy (same as x[:], 5.1.1 — shallow vs. deep copy explained below) | No | O(n) |
x = [3, 1, 2]
x.append(9)
x.extend([7, 8])
x.insert(0, 0)
print(x) # [0, 3, 1, 2, 9, 7, 8]
x.remove(9) # removes the VALUE 9, not index 9
print(x) # [0, 3, 1, 2, 7, 8]
last = x.pop() # removes & returns the last item
print(last, x) # 8 [0, 3, 1, 2, 7]
print(x.index(2), x.count(3)) # 3 1 -> index of value 2, how many 3s
[0, 3, 1, 2, 9, 7, 8] [0, 3, 1, 2, 7, 8] 8 [0, 3, 1, 2, 7] 3 1
Common mistake — sort() mutates in place and returns None (the built-in
sorted(), below, returns a new sorted list instead). Assigning the result of
sort() back to the variable silently destroys the list:
x = [3, 1, 2]
x = x.sort() # sort() returns None -> x is now None, the list is gone
print(x) # None
None
Shallow vs. deep copy — copy() (and x[:], 5.1.1) only copies one level deep:
the outer list is a new object, but any nested mutable object inside it (e.g. an inner
list) is still the same shared object as in the original.
| Copy type | How | Nested mutable objects |
|---|---|---|
| Assignment | b = a |
not a copy at all — b and a are the same object (2.4) |
| Shallow copy | a.copy(), a[:], copy.copy(a) |
new outer object, but nested objects are shared |
| Deep copy | copy.deepcopy(a) |
new outer object and new nested objects, recursively |
Mutating a nested item through a shallow copy is visible through the original too:
original = [[1, 2], [3, 4]]
shallow = original.copy()
shallow[0].append(99) # mutates the INNER list, which is shared with `original`
print(original) # [[1, 2, 99], [3, 4]] -> original sees the change too!
print(shallow) # [[1, 2, 99], [3, 4]]
print(original is shallow, original[0] is shallow[0]) # False True -> outer lists differ, inner lists don't
[[1, 2, 99], [3, 4]] [[1, 2, 99], [3, 4]] False True
A deep copy recursively copies every nested object too, so nothing is shared at any
level. There's no built-in list method for it — it needs the copy module:
import copy
original = [[1, 2], [3, 4]]
deep = copy.deepcopy(original)
deep[0].append(99)
print(original) # [[1, 2], [3, 4]] -> untouched
print(deep) # [[1, 2, 99], [3, 4]]
print(original[0] is deep[0]) # False -> inner lists are independent too, not just the outer one
[[1, 2], [3, 4]] [[1, 2, 99], [3, 4]] False
Built-in functions commonly used with lists — these aren't list methods, they're
standalone built-in functions that accept any iterable, list included, and work
identically on tuple, str, set, etc. Introduced here since list is the most
common target.
| Function | Returns | Time complexity |
|---|---|---|
list(iterable) |
a new list built from any iterable |
O(n) |
len(x) |
number of items | O(1) — lists track their own length, nothing to count |
sum(x) |
sum of numeric items | O(n) |
min(x) / max(x) |
smallest / largest item | O(n) |
sorted(x) |
a new sorted list — x itself is untouched |
O(n log n) |
reversed(x) |
a reverse iterator over x (not a list) |
O(1) to create, O(n) to fully consume |
enumerate(x) |
iterator of (index, item) pairs |
O(1) to create, O(n) to fully consume |
zip(x, y, ...) |
iterator pairing up items from multiple iterables | O(1) to create, O(n) to fully consume |
any(x) / all(x) |
True if any / all items are truthy |
O(n) worst case — short-circuits as soon as the answer is known |
print(list((1, 2, 3))) # [1, 2, 3] -> from a tuple
print(list("abc")) # ['a', 'b', 'c'] -> from a string
print(list(range(4))) # [0, 1, 2, 3] -> from a range
[1, 2, 3] ['a', 'b', 'c'] [0, 1, 2, 3]
x = [4, 1, 3]
print(len(x), sum(x), min(x), max(x)) # 3 8 1 4
print(sorted(x), x) # [1, 3, 4] [4, 1, 3] -> x itself is untouched
print(list(reversed(x))) # [3, 1, 4] -> reversed() is lazy, wrap in list() to see it
3 8 1 4 [1, 3, 4] [4, 1, 3] [3, 1, 4]
sorted() (and .sort(), same idea) accepts a key= function — called once per item to
compute the value actually used for comparison, without changing the items themselves:
people = [("Alice", 30), ("Bob", 25), ("Carol", 35)]
print(sorted(people, key=lambda t: t[1])) # [('Bob', 25), ('Alice', 30), ('Carol', 35)] -> sorted by age
words = ["Banana", "apple", "Cherry"]
print(sorted(words, key=str.lower)) # ['apple', 'Banana', 'Cherry'] -> case-insensitive sort
[('Bob', 25), ('Alice', 30), ('Carol', 35)]
['apple', 'Banana', 'Cherry']In practice — ranking API results. Sorting a list of product dicts by price, or
a list of search results by relevance score, with sorted(items, key=lambda x: x["price"])
is one of the most common real uses of the key= argument — ranking by a field without
writing a manual comparison function.
names = ["a", "b", "c"]
for i, name in enumerate(names):
print(i, name) # 0 a / 1 b / 2 c
nums = [1, 2, 3]
for name, n in zip(names, nums):
print(name, n) # a 1 / b 2 / c 3
0 a 1 b 2 c a 1 b 2 c 3
print(any([0, 0, 3]), all([1, 2, 3]), all([1, 0, 3])) # True True False
True True False
5.1.3 List Comprehension
A list comprehension builds a new list from an iterable in a single expression:
[expr for item in iterable if condition]. The if clause is optional — it filters
which items get processed.
squares = [n ** 2 for n in range(6)]
print(squares) # [0, 1, 4, 9, 16, 25]
evens = [n for n in range(10) if n % 2 == 0]
print(evens) # [0, 2, 4, 6, 8] -> the `if` filters which items are kept
[0, 1, 4, 9, 16, 25] [0, 2, 4, 6, 8]
Multiple for clauses flatten nested iteration into one line (equivalent to nested
for loops); a ternary (3.1.7) inside the expression picks a value per item instead of
filtering it out:
pairs = [(x, y) for x in range(2) for y in range(3)]
print(pairs) # [(0, 0), (0, 1), (0, 2), (1, 0), (1, 1), (1, 2)]
labels = ["even" if n % 2 == 0 else "odd" for n in range(4)]
print(labels) # ['even', 'odd', 'even', 'odd']
[(0, 0), (0, 1), (0, 2), (1, 0), (1, 1), (1, 2)] ['even', 'odd', 'even', 'odd']
Note — unlike a for loop, a comprehension's loop variable does not leak into
the surrounding scope — a comprehension runs in its own scope:
[n for n in range(3)]
try:
print(n)
except NameError as e:
print("NameError:", e) # n only existed inside the comprehension
3
5.1.4 Implementing Other Data Structures With list — Stack and Queue
Python doesn't need a dedicated Stack/Queue type for the simple cases — a plain
list is general-purpose enough to model both, using nothing but the methods from
5.1.2:
| Structure | Order | "Push" | "Pop" |
|---|---|---|---|
| Stack (LIFO — last in, first out) | last item added is the first removed | append(x) |
pop() — removes from the end |
| Queue (FIFO — first in, first out) | first item added is the first removed | append(x) |
pop(0) — removes from the front |
A stack — think "undo history":
stack = []
stack.append(1) # push
stack.append(2)
stack.append(3)
print(stack) # [1, 2, 3]
print(stack.pop()) # 3 -> the LAST one pushed is the FIRST one popped (LIFO)
print(stack) # [1, 2]
[1, 2, 3] 3 [1, 2]
A queue — think "people waiting in line":
queue = []
queue.append(1) # enqueue
queue.append(2)
queue.append(3)
print(queue) # [1, 2, 3]
print(queue.pop(0)) # 1 -> the FIRST one enqueued is the FIRST one dequeued (FIFO)
print(queue) # [2, 3]
[1, 2, 3] 1 [2, 3]
Common mistake — append()/pop() at the end of a list are O(1); pop(0) and
insert(0, x) at the front are O(n), since every remaining element has to shift over
one slot. For a stack this never matters, but a queue built this way gets slow as it
grows. collections.deque supports O(1) operations at both ends and is the right
choice for a real queue — it's outside this chapter's scope, but worth knowing it exists.
Membership testing, empirically — x in seq is O(n) for a list (has to scan element
by element) but O(1) on average for a set/dict (hash-based lookup, 5.3/5.4). Timing the
same check against a big collection makes the gap concrete:
import timeit
big_list = list(range(100_000))
big_set = set(big_list)
list_time = timeit.timeit(lambda: 99_999 in big_list, number=1000)
set_time = timeit.timeit(lambda: 99_999 in big_set, number=1000)
print(list_time > set_time) # True -> set membership is dramatically faster at this size
True
In practice — the standard "why is this endpoint slow" fix. Swapping a list
membership check (if user_id in banned_ids:) for a set once the collection grows
past a few hundred entries is a very common, real performance fix — the kind of change
that turns a linearly-degrading endpoint into a consistently fast one as data grows.
5.2 String
str is an immutable sequence of Unicode characters (2.2.4) — indexing, slicing, and
the general-purpose built-ins (len(), sorted(), ...) from 5.1.1/5.1.2 apply here
unchanged, just without any mutating operations, since a string can never be changed in
place. Every method below that looks like it "modifies" a string actually returns a
brand-new string, leaving the original untouched.
5.2.1 Creating Strings
Python offers several literal forms, plus ways to build a string programmatically:
| Form | Example | Notes |
|---|---|---|
| Single/double quotes | 'hi', "hi" |
interchangeable — pick whichever avoids escaping an internal quote |
| Triple quotes | '''hi''', """hi""" |
spans multiple lines; also used for docstrings |
str() constructor |
str(42) |
converts any object to its string representation |
| Raw string | r"C:\new" |
backslashes are literal — no escape sequences are processed |
| f-string | f"Hello, {name}!" |
embeds any expression directly inside the literal |
| Adjacent literals | "auto" "matic" |
concatenated automatically at compile time |
s1 = 'single'
s2 = "double"
s3 = '''triple
line'''
print(s1, s2) # single double
print(s3) # triple\nline -> spans both source lines
print(str(42), type(str(42))) # 42 <class 'str'>
single double triple line 42 <class 'str'>
Escape sequences (\n, \t, \\, ...) are processed inside normal strings but not
inside raw strings — useful for Windows paths or regex patterns (15). An f-string
embeds any expression inside {}, evaluated at runtime:
print(r"C:\new\test") # C:\new\test -> backslashes are literal, not escape codes
name = "World"
print(f"Hello, {name}!") # Hello, World! -> {name} is evaluated and inserted
print("auto" "matic") # automatic -> adjacent literals concatenate automatically
C:\new\test Hello, World! automatic
The : inside an f-string's {} introduces a format spec — controls for things like
decimal precision, alignment, and padding. !r (or !s, !a) is a conversion, applied
before the format spec, calling repr() (or str()/ascii()) on the value first:
pi = 3.14159
print(f"{pi:.2f}") # 3.14 -> 2 digits after the decimal point
print(f"{42:>6}") # " 42" -> right-aligned in a 6-character field
print(f"{42:<6}|") # "42 |" -> left-aligned, padding visible before the |
print(f"{'hi'!r}") # 'hi' -> repr() adds the quotes
3.14
42
42 |
'hi'5.2.2 Searching — find, index, rfind, rindex, count
Four methods search for a substring, differing in direction and failure
behavior. start/end narrow the search to a slice of the string, using the same
bounds rules as 5.1.1's slicing:
| Method | Signature | Direction | If not found |
|---|---|---|---|
find |
find(sub, start=0, end=len(s)) |
left to right | returns -1 |
index |
index(sub, start=0, end=len(s)) |
left to right | raises ValueError |
rfind |
rfind(sub, start=0, end=len(s)) |
right to left | returns -1 |
rindex |
rindex(sub, start=0, end=len(s)) |
right to left | raises ValueError |
count |
count(sub, start=0, end=len(s)) |
— | counts non-overlapping occurrences, 0 if none |
s = "Hello, World! Hello, Python!"
print(s.find("Hello")) # 0 -> first occurrence
print(s.find("Hello", 5)) # 14 -> search starts from index 5
print(s.find("xyz")) # -1 -> not found, no error
print(s.index("Hello")) # 0 -> same as find() when it succeeds
try:
s.index("xyz")
except ValueError as e:
print("ValueError:", e) # substring not found -> index() raises instead of returning -1
0 14 -1 0 ValueError: substring not found
print(s.rfind("Hello")) # 14 -> last occurrence, searching from the right
print(s.rindex("Hello")) # 14 -> same, but would raise ValueError if absent
print(s.count("Hello")) # 2 -> both occurrences
print(s.count("l", 0, 5)) # 2 -> only counts within s[0:5]
14 14 2 2
5.2.3 Replacing, Splitting, and Joining — replace, split, rsplit, join
| Method | Signature | Behaviour |
|---|---|---|
replace |
replace(old, new, count=-1) |
replaces occurrences of old with new, left to right; count caps how many (-1 = all) |
split |
split(sep=None, maxsplit=-1) |
splits into a list, left to right; sep=None splits on any whitespace run and drops empty strings |
rsplit |
rsplit(sep=None, maxsplit=-1) |
same as split, but maxsplit is counted from the right — only differs from split when maxsplit is given |
join |
sep.join(iterable) |
joins strings with sep between each — the inverse of split(), called on the separator, not the data |
print(s.replace("Hello", "Hi")) # Hi, World! Hi, Python! -> replaces every occurrence
print(s.replace("Hello", "Hi", 1)) # Hi, World! Hello, Python! -> count=1 stops after the first
Hi, World! Hi, Python! Hi, World! Hello, Python!
print("a,b,,c".split(",")) # ['a', 'b', '', 'c'] -> explicit sep keeps empty fields
print("a b c".split()) # ['a', 'b', 'c'] -> sep=None collapses whitespace runs, drops empties
print("a,b,c".split(",", 1)) # ['a', 'b,c'] -> maxsplit=1 stops after the first split
print("a,b,c".rsplit(",", 1)) # ['a,b', 'c'] -> maxsplit counted from the right instead
['a', 'b', '', 'c'] ['a', 'b', 'c'] ['a', 'b,c'] ['a,b', 'c']
print(",".join(["a", "b", "c"])) # a,b,c -> "," goes BETWEEN each item
print("".join(["x", "y", "z"])) # xyz -> empty separator just concatenates
a,b,c xyz
Building a string with repeated += is O(n²) — each += creates a brand-new string
(str is immutable, per this section's intro) and copies everything accumulated so far, so
the total work grows quadratically with the number of pieces. str.join() computes the
final size once and copies each piece exactly once — O(n). Timing the two makes the gap
concrete:
import timeit
items = [str(n) for n in range(2000)]
def with_plus():
s = ""
for x in items:
s += x
return s
def with_join():
return "".join(items)
plus_time = timeit.timeit(with_plus, number=200)
join_time = timeit.timeit(with_join, number=200)
print(plus_time > join_time) # True -> join() wins, and the gap widens as `items` grows
True
In practice — building a large log line or report string. Code that
accumulates output in a hot loop (building a report, a log line, a big HTML/CSV blob)
by repeated += is a real, commonly-flagged performance issue in code review —
"".join(parts) (or a list of pieces appended and joined once at the end) is the
standard fix real teams reach for.
Common mistake — join() only accepts an iterable of strings; joining a list of
numbers raises TypeError — convert each item with str() first:
try:
",".join([1, 2, 3])
except TypeError as e:
print("TypeError:", e)
print(",".join(str(n) for n in [1, 2, 3])) # 1,2,3 -> convert each item to str first
TypeError: sequence item 0: expected str instance, int found 1,2,3
5.2.4 Case Conversion — upper, lower, swapcase, title, capitalize
| Method | Effect |
|---|---|
upper() |
every character uppercase |
lower() |
every character lowercase |
swapcase() |
flips the case of every character |
title() |
capitalizes the first letter of each word |
capitalize() |
capitalizes only the first character of the whole string, lowercases the rest |
print("Hello".upper()) # HELLO
print("HELLO".lower()) # hello
print("Hello World".swapcase()) # hELLO wORLD
print("hello world".title()) # Hello World
print("hello world".capitalize()) # Hello world
print("HELLO WORLD".capitalize()) # Hello world -> the rest of the string is lowercased too
HELLO hello hELLO wORLD Hello World Hello world Hello world
5.2.5 Prefix/Suffix and Character-Class Checks — startswith, endswith, isalpha, and friends
startswith/endswith test the ends of a string; each also accepts a tuple to
check several options at once. The is* family tests what kind of characters a
string contains — every one of them returns False for an empty string:
| Method | True when the string... |
|---|---|
startswith(prefix, start=0, end=len(s)) |
...starts with prefix (or any item, if prefix is a tuple) |
endswith(suffix, start=0, end=len(s)) |
...ends with suffix (or any item, if suffix is a tuple) |
isalpha() |
...is non-empty and every character is a letter |
isdigit() |
...is non-empty and every character is a digit |
isalnum() |
...is non-empty and every character is a letter or digit |
isspace() |
...is non-empty and every character is whitespace |
isupper() / islower() |
...has at least one cased character, and they're all upper/lower |
print("Hello".startswith("He")) # True
print("Hello".startswith(("Hi", "He"))) # True -> matches if it starts with ANY item in the tuple
print("Hello".endswith("lo")) # True
True True True
print("Hello".isalpha(), "Hello123".isalpha()) # True False -> digits aren't letters
print("123".isdigit()) # True
print("Hello123".isalnum()) # True -> letters AND digits both count
print(" ".isspace()) # True
print("HELLO".isupper(), "hello".islower()) # True True
True False True True True True True
5.2.6 Trimming and Padding — strip, lstrip, rstrip, and alignment
| Method | Effect |
|---|---|
strip(chars=None) |
removes leading and trailing characters (whitespace by default) |
lstrip(chars=None) |
same, leading only |
rstrip(chars=None) |
same, trailing only |
ljust(width, fillchar=' ') |
pads on the right to reach width |
rjust(width, fillchar=' ') |
pads on the left to reach width |
center(width, fillchar=' ') |
pads on both sides to reach width |
zfill(width) |
pads on the left with '0' to reach width |
chars in strip/lstrip/rstrip isn't a substring to remove — it's a set of
characters, stripped from each end for as long as they keep matching:
print(" hi ".strip()) # 'hi' -> whitespace removed from both ends
print(" hi ".lstrip()) # 'hi ' -> only the left side
print(" hi ".rstrip()) # ' hi' -> only the right side
print("xxhixx".strip("x")) # 'hi' -> strips the characters 'x', not the substring "xx"
hi hi hi hi
print("7".zfill(3)) # 007
print("hi".center(6, "*")) # **hi**
print("hi".ljust(6, "-")) # hi----
print("hi".rjust(6, "-")) # ----hi
007 **hi** hi---- ----hi
5.3 Set and Frozenset
set and frozenset are unordered collections of unique, hashable items (2.1) — set
is mutable, frozenset is its immutable counterpart. Being unordered, neither supports
indexing or slicing (5.1.1) — there's no "first" or "last" element.
How they're stored — CPython implements both as a hash table: each element's
slot is derived from its hash() value (Note: hash() turns a value into a
number used to place it in the table — full explanation in 5.4.6), not its insertion order.
That's why iterating over a set can print items in a different order than they were added
(the order below is what this CPython build happens to produce — don't rely on it), and why
membership testing (x in s) is O(1) on average, instead of the O(n) linear scan a
list needs (5.1.1).
5.3.1 Creating Sets and Frozensets
| Form | Example | Notes |
|---|---|---|
| Set literal | {1, 2, 3} |
duplicates are silently dropped |
set() constructor |
set([1, 2, 2, 3]) |
builds a set from any iterable, deduplicating as it goes |
frozenset() constructor |
frozenset([1, 2, 3]) |
same idea, but the result is immutable |
Common mistake — {} is an empty dict (2.1), not an empty set; an empty set
can only be written as set(). Also, every element must be hashable (immutable) —
a list can't go inside a set, but a tuple can:
print(type({}), type(set())) # <class 'dict'> <class 'set'> -> {} is NOT an empty set
print(set([1, 2, 2, 3])) # {1, 2, 3} -> duplicates dropped
print(set("hello")) # {'e', 'h', 'l', 'o'} (some order) -> unique characters only
try:
{1, [2, 3]}
except TypeError as e:
print("TypeError:", e) # unhashable type: 'list'
print({1, (2, 3)}) # {(2, 3), 1} (some order) -> a tuple is hashable, so it's fine
<class 'dict'> <class 'set'>
{1, 2, 3}
{'l', 'e', 'h', 'o'}
TypeError: unhashable type: 'list'
{(2, 3), 1}Common mistake — a custom object is hashable by default (using its id()), but if you
override __eq__/__hash__ to be based on a mutable field, mutating that field after the
object is already in a set silently breaks lookups: the object is still physically inside
the set, but Python can no longer find the slot it's sitting in, since a fresh hash() call
now points somewhere else:
class Point:
def __init__(self, x):
self.x = x
def __eq__(self, other):
return isinstance(other, Point) and self.x == other.x
def __hash__(self):
return hash(self.x)
def __repr__(self):
return f"Point({self.x})"
p = Point(1)
s = {p}
print(p in s) # True -> found, hashes to the slot matching x=1
p.x = 2 # mutate the field the hash is based on
print(p in s) # False -> same object, but now hashes to a DIFFERENT slot -> "lost"
print(s) # {Point(2)} -> the object is still there, just unreachable by lookup
True
False
{Point(2)}In practice — a real caching-layer bug. Storing a custom object in a set/dict
and then mutating a field that its __eq__/__hash__ depends on is a genuine,
hard-to-diagnose production bug: the object silently becomes unreachable at its old
hash bucket, so lookups that should find it start failing with no error at all.
5.3.2 Set Methods
| Method | Effect | Mutates in place? | Time complexity |
|---|---|---|---|---|
| add(x) | add x if not already present | Yes | O(1) average |
| remove(x) | remove x (KeyError if absent) | Yes | O(1) average |
| discard(x) | remove x if present — no error if absent | Yes | O(1) average |
| pop() | remove and return an arbitrary element | Yes | O(1) average |
| clear() | remove all elements | Yes | O(n) |
| update(iterable) | add every item of iterable (in-place union) | Yes | O(k) — k = items added |
| copy() | shallow copy | No | O(n) |
pop() can't target a specific element the way list.pop(i) (5.1.2) can — a set has no
positions, so "arbitrary" really does mean whichever element the hash table gives up
first:
x = {1, 2, 3}
x.add(4)
print(x) # {1, 2, 3, 4}
x.add(2) # 2 is already there -> no effect, no error
print(x) # {1, 2, 3, 4}
try:
x.remove(99)
except KeyError as e:
print("KeyError:", e) # remove() raises when the value is absent
x.discard(99) # discard() doesn't -> silently does nothing
print(x) # {1, 2, 3, 4}
y = x.pop() # removes SOME element -> don't assume which one
print(y, x)
{1, 2, 3, 4}
{1, 2, 3, 4}
KeyError: 99
{1, 2, 3, 4}
1 {2, 3, 4}x.clear()
print(x) # set() -> empty
a = {1, 2, 3}
b = {2, 3, 4}
a.update(b) # in-place union -> adds every item of b into a
print(a) # {1, 2, 3, 4}
set()
{1, 2, 3, 4}5.3.3 Mathematical Set Operations
set implements the standard set-theory operations directly, each with both an
operator and an equivalent method:
| Operation | Operator | Method | Meaning |
|---|---|---|---|
| Union | \| |
union() |
elements in either set |
| Intersection | & |
intersection() |
elements in both sets |
| Difference | - |
difference() |
elements in the first set but not the second |
| Symmetric difference (XOR) | ^ |
symmetric_difference() |
elements in exactly one of the two sets |
Visualized for A = {1, 2, 3, 4} and B = {3, 4, 5, 6}:
A only A ∩ B B only
┌─────────┐ ┌─────────┐ ┌─────────┐
│ 1 2 │ │ 3 4 │ │ 5 6 │
└─────────┘ └─────────┘ └─────────┘
A | B (union) = {1, 2, 3, 4, 5, 6} <- every region
A & B (intersection) = {3, 4} <- middle region only
A - B (difference) = {1, 2} <- "A only" region only
B - A (difference) = {5, 6} <- "B only" region only
A ^ B (symmetric difference) = {1, 2, 5, 6} <- "A only" + "B only", middle excluded
A = {1, 2, 3, 4}
B = {3, 4, 5, 6}
print(A | B, "==", A.union(B)) # {1, 2, 3, 4, 5, 6} -> either set
print(A & B, "==", A.intersection(B)) # {3, 4} -> both sets
{1, 2, 3, 4, 5, 6} == {1, 2, 3, 4, 5, 6}
{3, 4} == {3, 4}print(A - B, "==", A.difference(B)) # {1, 2} -> in A but not B
print(B - A, "==", B.difference(A)) # {5, 6} -> in B but not A -> difference is NOT symmetric
{1, 2} == {1, 2}
{5, 6} == {5, 6}Symmetric difference (XOR) — unlike plain difference, ^ is symmetric: it's
everything that's in exactly one of the two sets, which is exactly (A - B) | (B - A):
print(A ^ B, "==", A.symmetric_difference(B)) # {1, 2, 5, 6}
print(A ^ B == (A - B) | (B - A)) # True -> confirms the equivalence
{1, 2, 5, 6} == {1, 2, 5, 6}
TrueEach operation also has an in-place form — |=/update(), &=/intersection_update(),
-=/difference_update(), ^=/symmetric_difference_update() — which mutate the left
operand instead of building a new set:
C = A.copy()
C |= B # same as C.update(B)
print(C) # {1, 2, 3, 4, 5, 6}
D = A.copy()
D ^= B # same as D.symmetric_difference_update(B)
print(D) # {1, 2, 5, 6}
{1, 2, 3, 4, 5, 6}
{1, 2, 5, 6}Three more methods test the relationship between two sets rather than combining them:
| Method | Operator | True when... |
|---|---|---|
issubset(other) |
<= |
every element of this set is also in other |
issuperset(other) |
>= |
every element of other is also in this set |
isdisjoint(other) |
— (no operator) | the two sets share no elements at all |
print({1, 2}.issubset({1, 2, 3}), {1, 2} <= {1, 2, 3}) # True True
print({1, 2, 3}.issuperset({1, 2}), {1, 2, 3} >= {1, 2}) # True True
print({1, 2}.isdisjoint({3, 4})) # True -> no elements in common
print({1, 2}.isdisjoint({2, 3})) # False -> they share 2
True True True True True False
5.3.4 Frozenset
frozenset supports every non-mutating operation from 5.3.3 (|, &, -, ^,
issubset, ...) — only add/remove/discard/pop/clear/update are missing,
since those all require mutation:
fs = frozenset([1, 2, 3])
print(fs, type(fs)) # frozenset({1, 2, 3}) <class 'frozenset'>
try:
fs.add(4)
except AttributeError as e:
print("AttributeError:", e) # frozenset has no add() -> it's immutable
print(fs | {4, 5}) # frozenset({1, 2, 3, 4, 5}) -> mixing frozenset and set still works, result stays a frozenset
frozenset({1, 2, 3}) <class 'frozenset'>
AttributeError: 'frozenset' object has no attribute 'add'
frozenset({1, 2, 3, 4, 5})Why frozenset exists — being immutable, it's also hashable, so it can be used
as a dictionary key or nested inside another set — something a plain set can never do,
since a mutable object's contents (and therefore its hash) could change after it's been
added:
try:
{{1, 2}, {3, 4}}
except TypeError as e:
print("TypeError:", e) # unhashable type: 'set' -> a set can't contain another set
print({frozenset({1, 2}), frozenset({3, 4})}) # {frozenset({1, 2}), frozenset({3, 4})} (some order) -> works fine, frozensets ARE hashable
TypeError: unhashable type: 'set'
{frozenset({3, 4}), frozenset({1, 2})}Sets also support a comprehension, mirroring list (5.1.3) and dict (5.4.5)
comprehensions: {expr for item in iterable if condition} — duplicates are dropped, same
as any other way of building a set.
squares = {x ** 2 for x in range(5)}
print(squares) # {0, 1, 4, 9, 16}
evens_mod = {x % 3 for x in range(10)}
print(evens_mod) # {0, 1, 2} -> duplicates collapse, order not guaranteed
{0, 1, 4, 9, 16}
{0, 1, 2}In practice — deduplicating IDs from overlapping sources. Combining user IDs
seen across several overlapping log files, or deduplicating tags collected from many
API responses, with a set comprehension ({row["id"] for row in rows}) is a common,
one-line real-world use of exactly this construct.
5.4 Dictionary
dict is a mutable collection of key-value pairs. Insertion order
is preserved (unlike set, 5.3) — iterating a dict yields keys in the order they were
first added.
How it's stored — a dict is a hash table too, same idea as set (5.3) — a set
is really just a dict with the values thrown away, storing only keys. Each key's
hash() determines its slot, which is what makes d[key] lookup, assignment, and
membership testing (key in d) O(1) on average, same as a set's membership test.
5.4.1 Creating Dictionaries
| Form | Example | Notes |
|---|---|---|
| Dict literal | {"a": 1, "b": 2} |
most common form |
dict() with keywords |
dict(a=1, b=2) |
keys must be valid identifiers, written without quotes |
dict() from pairs |
dict([("a", 1), ("b", 2)]) |
builds from any iterable of (key, value) pairs |
dict() from zip() |
dict(zip(["a", "b"], [1, 2])) |
pairs up two parallel iterables (5.1.2) into key/value pairs |
| Empty dict | {} |
unlike set, {} is the empty dict (5.3.1) |
All four non-empty forms below build the exact same dictionary:
d1 = {"a": 1, "b": 2}
d2 = dict(a=1, b=2)
d3 = dict([("a", 1), ("b", 2)])
d4 = dict(zip(["a", "b"], [1, 2]))
print(d1)
print(d1 == d2 == d3 == d4) # True -> four different ways to build the same dict
{'a': 1, 'b': 2}
True5.4.2 Accessing and Modifying
| Form | Effect |
|---|---|
d[key] |
look up a value — raises KeyError if key is absent |
d.get(key, default=None) |
same lookup, but returns default instead of raising |
d[key] = value |
add a new pair, or overwrite the value if key already exists |
del d[key] |
remove a pair (KeyError if absent) |
d.setdefault(key, default=None) |
return d[key] if present; otherwise insert key: default and return default |
print(d1["a"]) # 1
try:
d1["z"]
except KeyError as e:
print("KeyError:", e) # 'z' -> raised because the key is absent
print(d1.get("z")) # None -> get() never raises
print(d1.get("z", 0)) # 0 -> a custom default instead of None
d1["c"] = 3 # adds a new pair
print(d1) # {'a': 1, 'b': 2, 'c': 3}
del d1["a"] # removes a pair
print(d1) # {'b': 2, 'c': 3}
1
KeyError: 'z'
None
0
{'a': 1, 'b': 2, 'c': 3}
{'b': 2, 'c': 3}setdefault() is get() and d[key] = default combined into one atomic step — handy
for "insert only if missing" without checking first:
d5 = {"x": 1}
print(d5.setdefault("x", 99)) # 1 -> "x" already exists, its value is returned unchanged
print(d5.setdefault("y", 99)) # 99 -> "y" was missing, so it's inserted with this default
print(d5) # {'x': 1, 'y': 99}
1
99
{'x': 1, 'y': 99}In practice — grouping records into buckets. groups.setdefault(category, []).append(item)
is a very common real pattern for grouping a flat list of records by some key (orders
by customer, log lines by severity) into a dict of lists, in a single pass and without
a separate "does this key exist yet" check.
5.4.3 Dictionary Methods
| Method | Effect | Mutates in place? | Time complexity |
|---|---|---|---|---|
| keys() | view of all keys | No | O(1) to create, O(n) to consume |
| values() | view of all values | No | O(1) to create, O(n) to consume |
| items() | view of (key, value) pairs | No | O(1) to create, O(n) to consume |
| pop(key, default) | remove key and return its value (KeyError if absent and no default) | Yes | O(1) average |
| popitem() | remove and return the last-inserted pair | Yes | O(1) average |
| update(other) | merge another dict/iterable of pairs in, overwriting on key clashes | Yes | O(k) — k = items merged |
| clear() | remove all pairs | Yes | O(n) |
| copy() | shallow copy (5.1.2's shallow/deep copy distinction applies here too) | No | O(n) |
d6 = {"a": 1, "b": 2, "c": 3}
print(list(d6.keys())) # ['a', 'b', 'c']
print(list(d6.values())) # [1, 2, 3]
print(list(d6.items())) # [('a', 1), ('b', 2), ('c', 3)]
print(d6.pop("b")) # 2 -> removes "b", returns its value
print(d6) # {'a': 1, 'c': 3}
print(d6.pop("z", "n/a")) # n/a -> falls back to the default instead of raising KeyError
['a', 'b', 'c']
[1, 2, 3]
[('a', 1), ('b', 2), ('c', 3)]
2
{'a': 1, 'c': 3}
n/a.keys()/.values()/.items() return live views, not snapshots — if the dict changes
afterward, the view reflects the change immediately, without calling the method again:
d = {"a": 1, "b": 2}
keys_view = d.keys()
print(keys_view) # dict_keys(['a', 'b'])
d["c"] = 3
print(keys_view) # dict_keys(['a', 'b', 'c']) -> the view updated automatically
dict_keys(['a', 'b']) dict_keys(['a', 'b', 'c'])
d7 = {"a": 1, "b": 2}
print(d7.popitem()) # ('b', 2) -> the most recently inserted pair
print(d7) # {'a': 1}
d8 = {"a": 1}
d8.update({"b": 2, "c": 3}) # merges in another dict's pairs
print(d8) # {'a': 1, 'b': 2, 'c': 3}
('b', 2)
{'a': 1}
{'a': 1, 'b': 2, 'c': 3}| and |= merge dicts
directly — d1 | d2 builds a new dict (values from d2 win on key clashes), and
d1 |= d2 merges in place, the same result as d1.update(d2):
d1 = {"a": 1, "b": 2}
d2 = {"b": 20, "c": 3}
print(d1 | d2) # {'a': 1, 'b': 20, 'c': 3} -> new dict, d2's value wins for 'b'
print(d1) # {'a': 1, 'b': 2} -> d1 itself is untouched by |
d1 |= d2
print(d1) # {'a': 1, 'b': 20, 'c': 3} -> now merged in place, same as d1.update(d2)
{'a': 1, 'b': 20, 'c': 3}
{'a': 1, 'b': 2}
{'a': 1, 'b': 20, 'c': 3}In practice — merging a default config with user overrides. {**DEFAULT_CONFIG, **user_settings}
is a common real pattern for applying a set of overrides on top of a base configuration
— whatever the user actually supplied wins, and anything they didn't specify falls back
to the default, all in one expression.
5.4.4 Iterating Over a Dictionary
Iterating a dict directly gives just the keys; loop over .items() to get both
key and value together (via unpacking, 3.1.6), in insertion order:
scores = {"alice": 90, "bob": 85}
for name in scores:
print(name) # alice / bob -> iterating a dict gives keys only
for name, score in scores.items():
print(name, score) # alice 90 / bob 85
alice bob alice 90 bob 85
5.4.5 Dictionary Comprehension
Same idea as a list comprehension (5.1.3), but builds key: value pairs instead of
single elements: {key_expr: value_expr for item in iterable if condition}.
squares = {n: n ** 2 for n in range(5)}
print(squares) # {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}
evens = {n: n ** 2 for n in range(10) if n % 2 == 0}
print(evens) # {0: 0, 2: 4, 4: 16, 6: 36, 8: 64} -> the `if` filters which keys get included
{0: 0, 1: 1, 2: 4, 3: 9, 4: 16}
{0: 0, 2: 4, 4: 16, 6: 36, 8: 64}5.4.6 Hashing and Dictionary Keys
The built-in hash(x) turns any hashable object into an integer — that integer is what
picks the slot in the underlying hash table (see 5.4's "How it's stored" note above).
Two rules make this work correctly:
- A key must be hashable — in practice, immutable (2.2/2.3):
int,float,str,tuple,frozenset(5.3.4), etc.list,set, anddictare all mutable and have nohash()at all, so none of them can be a dict key — the same rule 5.3.1 already applies to set elements, since a set is just keys without values. - Equal objects must hash equal — Python guarantees
a == bimplieshash(a) == hash(b), so a lookup can trust the hash to find the right slot.
print(hash(42)) # 42 -> small ints hash to themselves
print(hash("hello")) # some large int -> strings ARE hashable, but the exact value varies between runs
print(hash((1, 2))) # a tuple is hashable, since it's immutable
try:
hash([1, 2])
except TypeError as e:
print("TypeError:", e) # unhashable type: 'list' -> the same rule as set elements (5.3.1)
42 -8486896050944012740 -3550055125485641917 TypeError: unhashable type: 'list'
A hashable-but-not-atomic key like a tuple is a common, practical pattern — e.g. using
(x, y) coordinates as dict keys directly, instead of building an artificial string key:
grid = {}
grid[(1, 2)] = "point A"
grid[(3, 4)] = "point B"
print(grid) # {(1, 2): 'point A', (3, 4): 'point B'}
print(grid[(1, 2)]) # point A -> the tuple works exactly like any other key
{(1, 2): 'point A', (3, 4): 'point B'}
point AIn practice — caching by multiple arguments, or a game/geospatial grid. A cache
keyed by (user_id, page_number), or a game board/spatial grid indexed by (row, col)
coordinates, both rely on exactly this: a tuple's hashability makes it usable as a
dict key in a way a list never could be.
Common mistake — Python's numeric types form a single "numeric tower": bool is
actually a subclass of int (True equals 1, False equals 0 — though True is 1 is
still False, since they're different objects, 3.1.8), and int/float compare equal
whenever they represent the same mathematical value — so 1 == 1.0 == True. Because equal
objects are required to hash equal (rule 2 above), they all hash equal too, so using them
as separate dict keys doesn't create three entries — it's the same slot three times.
The value gets overwritten on each write, but the key object stays whichever one was
inserted first:
print(1 == 1.0 == True) # True -> int, float and bool all compare equal for the same value
True
print(hash(1) == hash(1.0) == hash(True)) # True -> equal values, equal hashes
collapsed = {1: "int one", 1.0: "float one", True: "bool true"}
print(collapsed) # {1: 'bool true'} -> just ONE entry, not three
print(list(collapsed)[0]) # 1 -> the key is still the originally-inserted int
True
{1: 'bool true'}
15.5 Tuple
tuple is an ordered, immutable sequence (2.3.2) — indexing, slicing, and the
general-purpose built-ins from 5.1.1/5.1.2 apply exactly as they do on list. Being
immutable, there are no in-place mutating methods at all (no append, insert,
remove, ...) — the same story as str (5.2).
How it's stored — a tuple uses the same contiguous array of references as a
list (5.1.1), but since it can never grow or shrink, CPython allocates it at exactly
the size needed — no over-allocated spare capacity the way a list keeps for cheap
append(). That makes a tuple slightly smaller in memory than the equivalent list:
import sys
print(sys.getsizeof((1, 2, 3))) # 64 bytes
print(sys.getsizeof([1, 2, 3])) # 88 bytes -> the list carries spare capacity, the tuple doesn't
64 88
5.5.1 Creating Tuples
| Form | Example | Notes |
|---|---|---|
| Tuple literal | (1, 2, 3) |
parentheses are the usual convention |
| Comma without parentheses | 1, 2, 3 |
it's the comma, not the parentheses, that actually makes a tuple |
tuple() constructor |
tuple([1, 2, 3]) |
builds a tuple from any iterable |
| Empty tuple | () |
the one case parentheses alone are required |
Common mistake — a single-element tuple needs a trailing comma; without it,
(x) is just x wrapped in redundant parentheses, not a tuple at all:
single = (1,)
not_a_tuple = (1)
print(type(single), type(not_a_tuple)) # <class 'tuple'> <class 'int'> -> the comma is what matters, not the parens
t1 = (1, 2, 3)
t2 = 1, 2, 3
print(t1 == t2) # True -> parentheses were never required here either
print(tuple([1, 2, 3])) # (1, 2, 3)
print(tuple("abc")) # ('a', 'b', 'c')
print(tuple()) # ()
<class 'tuple'> <class 'int'>
True
(1, 2, 3)
('a', 'b', 'c')
()Tuples can nest, and can hold mutable elements too — only the tuple's own slots are fixed; a mutable element inside one can still be changed in place (5.1.2):
nested = (1, (2, 3), [4, 5])
print(nested) # (1, (2, 3), [4, 5])
nested[2].append(6) # the LIST inside is still mutable
print(nested) # (1, (2, 3), [4, 5, 6])
try:
nested[0] = 99 # but the tuple's own slots can't be reassigned
except TypeError as e:
print("TypeError:", e) # 'tuple' object does not support item assignment
(1, (2, 3), [4, 5]) (1, (2, 3), [4, 5, 6]) TypeError: 'tuple' object does not support item assignment
5.5.2 Tuple Methods
Only two — immutability rules out every method that would need to add, remove, or reorder elements:
| Method | Effect |
|---|---|
count(x) |
number of occurrences of x |
index(x) |
index of the first occurrence of x (ValueError if absent, same as list.index(), 5.1.2) |
t = (1, 2, 3, 2, 1)
print(t.count(2)) # 2 -> appears twice
print(t.index(3)) # 2 -> first (and only) occurrence is at index 2
2 2
5.5.3 Tuple Unpacking
3.1.6 already introduced basic unpacking (a, b = 1, 2); tuples support several more
patterns built on the same idea. Basic unpacking requires exactly as many names as
values:
a, b, c = (1, 2, 3)
print(a, b, c) # 1 2 3
a, b = b, a # the classic swap idiom -> the right side builds a tuple (b, a) first, THEN unpacks it
print(a, b) # 2 1
1 2 3 2 1
Extended unpacking with *name collects "everything else" into a list, so the
number of names no longer has to match exactly — *name can go first, last, or in the
middle, and always ends up a list, even when unpacking a tuple:
first, *rest = (1, 2, 3, 4)
print(first, rest) # 1 [2, 3, 4] -> rest is a list, not a tuple
first, *middle, last = (1, 2, 3, 4, 5)
print(first, middle, last) # 1 [2, 3, 4] 5
1 [2, 3, 4] 1 [2, 3, 4] 5
Unpacking can also be nested, matching the shape of the tuple on the right; a bare
_ is a common convention for a value you're deliberately throwing away:
(a, b), c = (1, 2), 3
print(a, b, c) # 1 2 3 -> the shape on the left mirrors the nested shape on the right
a, _, c = (1, 2, 3)
print(a, c) # 1 3 -> the middle value (2) is discarded
1 2 3 1 3
A function returning several values is really just returning one tuple — unpacking it on the caller's side is what makes it feel like "multiple return values" (6 covers functions in depth):
def minmax(nums):
return min(nums), max(nums) # this is really `return (min(nums), max(nums))`
result = minmax([4, 1, 9, 2])
print(result, type(result)) # (1, 9) <class 'tuple'>
lo, hi = minmax([4, 1, 9, 2]) # unpacked directly into two names
print(lo, hi) # 1 9
(1, 9) <class 'tuple'> 1 9
In practice — a function returning (status_code, body). Many real APIs and web
handlers return a small tuple of related values this way — return status_code, body
unpacked at the call site — instead of defining a one-off class just to bundle two or
three related values together.
5.5.4 Why Use a Tuple?
Beyond the minor memory saving (5.5's intro), immutability is what makes a tuple hashable — but only if every element inside it is also hashable (5.3.1, 5.4.6), since a tuple's hash is derived from all its elements' hashes:
print(hash((1, 2, 3))) # some int -> hashable, since 1, 2, and 3 all are
try:
hash((1, 2, [3, 4]))
except TypeError as e:
print("TypeError:", e) # unhashable type: 'list' -> one unhashable element ruins the whole tuple
529344067295497451 TypeError: unhashable type: 'list'
That's exactly what already made (1, 2) and (3, 4) valid dict keys back in 5.4.6 —
a list could never have worked there. Beyond hashability, a tuple is also the natural
choice whenever a collection's size and meaning are fixed (e.g. an (x, y) coordinate,
a (name, age) record) — using list there would incorrectly imply the number of items
might change. collections.namedtuple builds on exactly this idea, adding named field
access on top; it's outside this chapter's scope, but worth knowing it exists.