Gradient AtlasML Materials

Data science roadmap › Python for Data Science › Chapter 5

Built-in Data Types: Operations and Methods

The practical working surface of each built-in type — operations, methods, behaviour.

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.

In [1]6 lines
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:

In [2]2 lines
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:

In [3]1 line
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:

In [4]2 lines
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:

In [5]2 lines
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.

  1. Predict x[-7:-3] before running it. Which case is this (forward or backward)?
  2. Predict x[-3:-7:-1].
  3. Without running it, say whether x[3:-3] is empty or not, and why.
  4. Write a slice that produces ['h', 'g', 'f'] using only negative indices.
  5. Why does x[-1:-8:-1] leave out 'a', and how would you fix it so 'a' is included?
  6. x[1:5:0] raises an error rather than returning an empty list — reason out why a step of 0 is 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:

In [6]7 lines
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):

In [7]4 lines
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):

In [8]4 lines
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) |

In [9]13 lines
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:

In [10]3 lines
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:

In [11]7 lines
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:

In [12]9 lines
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
In [13]3 lines
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]
In [14]4 lines
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:

In [15]5 lines
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.

In [16]7 lines
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
In [17]1 line
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.

In [18]5 lines
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:

In [19]5 lines
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:

In [20]5 lines
[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":

In [21]8 lines
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":

In [22]8 lines
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:

In [23]8 lines
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
In [24]8 lines
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:

In [25]5 lines
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:

In [26]5 lines
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
In [27]10 lines
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
In [28]5 lines
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
In [29]2 lines
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!
In [30]4 lines
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']
In [31]2 lines
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:

In [32]16 lines
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:

In [33]6 lines
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
In [34]6 lines
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
In [35]3 lines
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
In [36]5 lines
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:

In [37]4 lines
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
In [38]4 lines
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:

In [39]10 lines
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:

In [40]17 lines
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:

In [41]15 lines
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}
In [42]7 lines
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
In [43]5 lines
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}
In [44]2 lines
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):

In [45]2 lines
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}
True

Each 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:

In [46]7 lines
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
In [47]4 lines
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:

In [48]9 lines
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:

In [49]6 lines
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.

In [50]5 lines
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:

In [51]6 lines
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}
True

5.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
In [52]13 lines
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:

In [53]4 lines
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) |

In [54]8 lines
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:

In [55]6 lines
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'])
In [56]7 lines
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):

In [57]8 lines
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:

In [58]7 lines
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}.

In [59]5 lines
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:

  1. A key must be hashable — in practice, immutable (2.2/2.3): int, float, str, tuple, frozenset (5.3.4), etc. list, set, and dict are all mutable and have no hash() 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.
  2. Equal objects must hash equal — Python guarantees a == b implies hash(a) == hash(b), so a lookup can trust the hash to find the right slot.
In [60]8 lines
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:

In [61]5 lines
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 A

In 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:

In [62]1 line
print(1 == 1.0 == True)   # True -> int, float and bool all compare equal for the same value
True
In [63]5 lines
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'}
1

5.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:

In [64]3 lines
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:

In [65]11 lines
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):

In [66]10 lines
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)
In [67]3 lines
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:

In [68]5 lines
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:

In [69]5 lines
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:

In [70]5 lines
(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):

In [71]8 lines
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:

In [72]6 lines
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.