Tuples, Immutability, and Unpacking
Context
A tuple holds several values in order like a list, but once made it cannot be changed. Its contents can be pulled apart into separate names in a single step, which also lets several names be assigned at once.
Fixed groupings are what tuples are for. The dimensions of a table, the pair of values a function returns when it has to report both a result and a count, the coordinate identifying one cell, all of these are groups where changing one part after the fact would mean the group no longer describes anything real. Pulling the pair apart at the call site is what makes a function returning two things readable rather than a puzzle.
Reaching for a list where a tuple belongs removes a guarantee you were relying on. A grouping that should be fixed becomes something any later function can quietly edit, and the error surfaces far from the edit. The reverse mistake is louder, an attempt to change a tuple stops the program immediately, which is the point. Unpacking that expects a different number of values than it receives fails the same way. The previous lesson introduced ordered collections that change, this lesson introduces the ones that must not.
Goal
- Construct a tuple with and without parentheses, and identify a single-item tuple correctly.
- Explain precisely what immutability forbids, and predict which tuple operations raise an error.
- Unpack a tuple into several named variables in one statement, including with a starred catch-all.
- Use multiple assignment to bind several variables at once and to swap values without a temporary.
- Recognize that returning several values from a function is returning one tuple, and unpack that result at the call site.
Prerequisites
Both optional. The lesson does not assume either, they are here if you want the contrast with lists drawn for you first.
- YouTube - "Python Tutorial for Beginners 4: Lists, Tuples, and Sets" by Corey Schafer. Watch the tuple segment for the mutability contrast.
- Official docs - The Python Tutorial, "Tuples and Sequences". Read the tuple part, especially the single-item and packing/unpacking notes.
Table of Contents
- Tuple
- Immutability
- Tuple Unpacking
- Multiple Assignment
Theory
A fixed (row, column) coordinate, an unchangeable record key, a pair of (value, error) results, each of these is exactly what a tuple is for. You will see tuples returned wherever a value must not be edited after it is produced. This lesson builds directly on the list, using it as the thing a tuple is deliberately not.
Tuple
What. A tuple is an ordered, immutable sequence of values. Ordered means it supports the same zero-based indexing and slicing as a list. Immutable means once built, its contents can never change.
Why. Sometimes a group of values belongs together permanently, a coordinate, a date as (year, month, day), the multiple results of one computation. A tuple says, in code, "these go together and must not be edited", which prevents a whole class of accidental-modification bugs and lets the value be used in places a list cannot go.
When. Use a tuple for a fixed-size, fixed-content grouping, especially a heterogeneous one where each position has a distinct meaning. Use a list instead when the collection may grow, shrink, or be reordered.
A tuple is written with commas, optionally wrapped in parentheses. It is the comma that makes a tuple, not the parentheses, though parentheses are good style and sometimes required for clarity.
# A tuple of three values. Parentheses are conventional, the commas are essential.
point = (3, 7, 2)
# The parentheses are optional, the commas alone create the tuple.
also_point = 3, 7, 2 # 'also_point' is the same (3, 7, 2)
# Indexing and slicing work exactly as on lists.
x = point[0] # 'x' is 3
tail = point[1:] # 'tail' is the tuple (7, 2)Everything you learned about reading a list applies unchanged, and note that slicing a tuple hands back a tuple rather than a list.
The single-item tuple is a famous trap. A value in parentheses is just that value, you need a trailing comma to make it a one-item tuple.
# WITHOUT a trailing comma this is just the integer 5 in parentheses.
not_a_tuple = (5) # 'not_a_tuple' is the int 5
# WITH a trailing comma it is a one-item tuple.
one_tuple = (5,) # 'one_tuple' is the tuple (5,)
print(type(not_a_tuple)) # <class 'int'>
print(type(one_tuple)) # <class 'tuple'>The parentheses in (5) are the ordinary grouping parentheses of arithmetic, doing exactly what they do in (2 + 3) * 4, which is why they build nothing. In a literal, only the comma constructs a tuple, and the empty tuple () is the one exception.
A tuple is an iterable, so a for loop walks it just like a list, and len counts its items.
record = ("setosa", 5.1, 3.5)
for field in record: # visits "setosa", then 5.1, then 3.5
print(field)
print(len(record)) # 3Reading a tuple is reading a list in every respect, only writing is forbidden.
DON'T:
- DON'T forget the trailing comma for a one-item tuple.
(5)is a number,(5,)is a tuple. - DON'T assume the parentheses are what create the tuple. The commas do,
1, 2, 3is already a tuple.
Immutability
What. Immutability means a value cannot be changed after it is created. For a tuple, that means you cannot reassign an item, append to it, remove from it, or sort it in place. Why. A value that can never change is safe to share, safe to use as a fixed key, and impossible to corrupt by accident from another part of the program. It also signals intent: "this grouping is final". You met a soft version of this in the last lesson, a slice always returns a new list rather than editing the source. Immutability is that idea made absolute. When. Choose an immutable tuple when a grouping must stay constant for the life of the program, when you need a value that can serve as a dictionary key (next lesson), or whenever accidental modification would be a bug rather than a feature.
Assigning to a tuple's item raises a TypeError. There are no mutating methods like append or sort on a tuple at all.
point = (3, 7, 2)
# Reassigning an item is forbidden.
point[0] = 99 # raises TypeError: 'tuple' object does not support item assignment
# There is no append, no sort, no pop on a tuple.
point.append(5) # raises AttributeError: 'tuple' object has no attribute 'append'The two errors differ for a reason worth noticing. TypeError says the operation exists but tuples refuse it, while AttributeError says there is no append on a tuple at all, the method was never written. Immutability is not a check performed at the last moment, it is the absence of any machinery to mutate.
"Cannot change" means you build a new tuple instead, just as a slice built a new list. Reassigning the variable is fine, that points the name at a different tuple, it does not mutate the old one.
point = (3, 7, 2)
# This does NOT mutate the tuple, it builds a new one and rebinds the name.
point = point + (5,) # 'point' now refers to a new tuple (3, 7, 2, 5)
# Slicing also returns a new tuple, never editing the source.
front = point[:2] # 'front' is (3, 7)The original (3, 7, 2) was not modified, it was abandoned, and the name now points somewhere else. The pattern is identical to building a new list from a slice, just made mandatory.
There is one sharp edge worth naming: immutability is shallow. A tuple's own structure is fixed, but if it contains a mutable object like a list, that inner object can still be changed.
# The tuple is immutable, but its second item is a mutable list.
row = ("id_1", [0.5, 0.2])
# You cannot replace the list itself...
row[1] = [9.9] # raises TypeError
# ...but you CAN mutate the list that is already inside.
row[1].append(0.9) # allowed, 'row' is now ("id_1", [0.5, 0.2, 0.9])Immutability protects the tuple's own slots, not the deep contents of the mutable items sitting in them. The tuple's promise is only that slot 1 will always point at that same list, and it says nothing at all about what the list contains.
DON'T:
- DON'T try to "edit" a tuple with item assignment or
append. Build a new tuple with+or a slice and rebind the name. - DON'T assume a tuple's contents are deeply frozen. A mutable object inside it can still be mutated through the tuple.
Tuple Unpacking
What. Tuple unpacking binds several names from one sequence in a single statement, matching each name to an item by position.
Why. When a value is a fixed grouping, a coordinate, a pair, a record, you usually want its parts under meaningful names, not buried behind [0] and [1]. Unpacking reads far better than indexing and makes the shape of the data obvious at a glance.
When. Unpack whenever you receive a tuple (or any fixed-length sequence) whose positions have distinct meanings, especially the return value of a function that produces several results.
The names on the left are matched, in order, to the items on the right. The count must agree, or Python raises a ValueError.
point = (3, 7, 2)
# Three names on the left, three items on the right.
x, y, z = point # 'x' is 3, 'y' is 7, 'z' is 2
# This is far clearer than indexing each position.
print(x, y, z) # 3 7 2
# A count mismatch is an error, not a silent truncation.
a, b = point # raises ValueError: too many values to unpack (expected 2)The final line fails because Python refuses to guess which item to drop.
When you want to capture a known few and lump the rest together, a starred name collects the remaining items into a list.
scores = (0.9, 0.8, 0.7, 0.6, 0.5)
# 'first' takes one item, '*rest' collects everything left into a LIST.
first, *rest = scores # 'first' is 0.9, 'rest' is [0.8, 0.7, 0.6, 0.5]
# The star can sit anywhere, capturing the middle.
head, *middle, tail = scores # 'head' 0.9, 'middle' [0.8,0.7,0.6], 'tail' 0.5Note the starred catch-all is always a list, even when what you unpacked was a tuple, so rest can be appended to while scores cannot.
Unpacking pairs naturally with a for loop over a sequence of tuples, letting you name each part inside the loop.
records = [("setosa", 5.1), ("versicolor", 6.4), ("virginica", 5.9)]
# Each loop item is a 2-tuple, unpacked into 'name' and 'length'.
for name, length in records:
print(name, length) # setosa 5.1, then the next pairsThis loop-with-unpacking pattern is everywhere in data work, and it is the reason so many functions hand back tuples in the first place.
DON'T:
- DON'T unpack into the wrong number of names. Three items need three names, or a starred name to absorb the extras, otherwise it is a
ValueError. - DON'T forget the starred catch-all is a list. Even unpacking a tuple,
*restproduces a list, not a tuple.
Multiple Assignment
What. Multiple assignment binds several variables in one statement by writing comma-separated targets on the left and comma-separated values on the right. Why. It removes ceremony and a class of bugs. Initializing related variables together reads cleanly, and swapping two values needs no temporary variable at all, which is both shorter and impossible to get wrong. When. Use it to initialize several related variables at once, to swap values, and as the mechanism behind unpacking a function's multiple return values.
The left side and right side are paired by position. Under the hood the right side is packed into a tuple and then unpacked into the targets, multiple assignment and tuple unpacking are the same mechanism.
# Bind three variables in one line, paired by position.
epochs, lr, batch = 10, 0.01, 32 # 'epochs' 10, 'lr' 0.01, 'batch' 32
# This is exactly unpacking the tuple (10, 0.01, 32).
print(epochs, lr, batch) # 10 0.01 32It is the same machinery as the previous subtitle, just written without an explicit tuple variable in the middle.
The signature use is swapping two values with no temporary, because the right side is fully evaluated before any assignment happens.
a = 1
b = 2
# Swap without a temp: the right side (b, a) is built first, then unpacked.
a, b = b, a # 'a' is now 2, 'b' is now 1
print(a, b) # 2 1The order matters and it is the whole trick: the right side is fully evaluated into (2, 1) before any name on the left is touched, so the old values are already captured by the time a is overwritten. A hand-written a = b followed by b = a fails precisely because it lacks that separation.
This same mechanism is what lets a function "return several values". A function that returns multiple values is really returning one tuple, which you then unpack at the call site.
# A function returning two values is returning ONE tuple, (mean, count).
def summarize(values):
total = 0
for v in values: # accumulate the running total
total += v
mean = total / len(values) # average of the values
return mean, len(values) # returns the tuple (mean, count)
# Unpack the returned tuple into two names at the call site.
avg, n = summarize([2.0, 4.0, 6.0]) # 'avg' is 4.0, 'n' is 3
print(avg, n) # 4.0 3
# Or capture the whole tuple without unpacking.
result = summarize([1.0, 3.0]) # 'result' is the tuple (2.0, 2)A function never returns two things. return mean, len(values) builds one tuple and returns that, and the choice at the call site is only whether to take it apart on arrival or hold it whole.
DON'T:
- DON'T use a temporary variable to swap (
tmp = a; a = b; b = tmp). Writea, b = b, a, it is shorter and cannot get the order wrong. - DON'T think a function returns "two things". It returns one tuple, and you choose whether to unpack it or keep it whole.
Lab Exercises
Lab Exercise 1: Build Fixed Records, Then Try to Break One
Create a tuple coord for the point (4, 9), a one-item tuple single holding just 42, and a three-field record sample holding "versicolor", 6.4 and True. Print type(single) to prove it is a tuple rather than an integer, then print the first and last fields of sample by indexing and print len(sample).
Then, starting from config = (10, 0.01, 32), write two commented-out lines that would each fail, one reassigning config[0] and one calling config.append(64), and name the exact error each would raise in a comment beside it. Build a NEW tuple config2 that is config with 64 added, print both, and show config is unchanged.
Finally, given mixed = ("a", [1, 2]), append 3 to the inner list and print mixed. Add a comment explaining why that is allowed when the two lines above are not.
Lab Exercise 2: Unpack, Swap, and Return Several Values
Given pair = (5.1, 3.5), unpack it into sepal_length and sepal_width and print both. Given scores = (0.9, 0.8, 0.7, 0.6), use starred unpacking to bind best to the first and others to the rest, print both, and print type(others) to show what the starred name produced.
Then swap low = 1 and high = 9 with multiple assignment and print them afterwards.
Finally write min_max_mean(values) that returns the minimum, maximum and mean as one tuple. Call it on [3.0, 1.0, 4.0, 1.0, 5.0], unpack the result into three names, and print them. Then call it again without unpacking, print the whole result and its len, and add a comment stating how many values the function actually returned.
Solution 1: Build Fixed Records, Then Try to Break One
# A two-item coordinate tuple.
coord = (4, 9)
# A ONE-item tuple needs the trailing comma, or it is just an int.
single = (42,)
print(type(single)) # <class 'tuple'>
# A heterogeneous record: each position has a distinct meaning.
sample = ("versicolor", 6.4, True)
print(sample[0]) # versicolor
print(sample[-1]) # True
print(len(sample)) # 3config = (10, 0.01, 32)
# config[0] = 99 # TypeError: 'tuple' object does not support item assignment
# config.append(64) # AttributeError: 'tuple' object has no attribute 'append'
# Build a NEW tuple rather than mutating, and bind it to a new name.
config2 = config + (64,) # '+' concatenates into a fresh tuple
print(config) # (10, 0.01, 32)
print(config2) # (10, 0.01, 32, 64)
# Shallow immutability: the tuple's slots are fixed, the list inside is not.
mixed = ("a", [1, 2])
mixed[1].append(3) # allowed, nothing about the tuple changed
print(mixed) # ('a', [1, 2, 3])
# The two commented lines try to change the slots themselves. This line
# leaves slot 1 pointing at the same list and changes the list instead.DON'T:
- DON'T write
single = (42)and expect a tuple, that is the integer42in grouping parentheses. - DON'T read
mixedprinting differently as evidence the tuple changed, the tuple holds the same two objects it always did.
Solution 2: Unpack, Swap, and Return Several Values
pair = (5.1, 3.5)
# Two names, two items, matched by position.
sepal_length, sepal_width = pair
print(sepal_length, sepal_width) # 5.1 3.5
scores = (0.9, 0.8, 0.7, 0.6)
# 'best' takes one item, '*others' collects everything left.
best, *others = scores
print(best, others) # 0.9 [0.8, 0.7, 0.6]
print(type(others)) # <class 'list'>
low = 1
high = 9
# The right side is built first, so no temporary is needed.
low, high = high, low
print(low, high) # 9 1# Returns ONE tuple of three summary values.
def min_max_mean(values):
lowest = values[0] # seed with the first value
highest = values[0]
total = 0
for v in values: # one pass finds all three
if v < lowest:
lowest = v
if v > highest:
highest = v
total += v # accumulate for the mean
# the tuple (min, max, mean)
return lowest, highest, total / len(values)
lo, hi, avg = min_max_mean([3.0, 1.0, 4.0, 1.0, 5.0])
print(lo, hi, avg) # 1.0 5.0 2.8
whole = min_max_mean([3.0, 1.0, 4.0, 1.0, 5.0])
print(whole, len(whole)) # (1.0, 5.0, 2.8) 3
# The function returned ONE value, a 3-tuple. Unpacking is a choice at the call
# site, not something the function did.DON'T:
- DON'T unpack
scoresinto two plain names, four items into two names is aValueError. - DON'T describe
min_max_meanas returning three values, it returns one tuple holding three.
Theory Assessment
Q1: What the Comma Builds, and What Still Works
What is the difference between (7) and (7,)? State the type of each and why. Then, given t = (10, 20, 30, 40), say which of t[0], t[-1] and t[1:3] are valid and what each returns, and which of t.append(50) and t[0] = 99 are valid.
Hint: It is the comma that builds a tuple, and the parentheses in (7) are the same ones you use in arithmetic. For the second half, sort the operations into those that read and those that write.
Q2: Allowed, Forbidden, and the Line Between Them
Explain why t = t + (50,) is allowed for a tuple even though tuples are immutable, and say what becomes of the original tuple. Then, given row = ("id", [1, 2]), explain why row[1] = [9] raises an error while row[1].append(9) does not.
Hint: In both halves the question is what exactly is being changed, the object itself, the name pointing at it, or something the object merely points at.
Q3: Counting Names, and Why the Swap Works
For p = (1, 2, 3), give the resulting bindings or the error for each of a, b, c = p, a, b = p, and a, *b = p. Then explain, step by step, why a, b = b, a swaps two values correctly with no temporary variable.
Hint: Names and items must match one for one unless a starred name absorbs the remainder. For the swap, the question is what has already been computed at the moment the first name is overwritten.
Q4: One Statement, Two Names, Two Different Fates
Read the following without running it, then state what each print shows.
row = ("id_1", [0.5, 0.2]) # a tuple holding a string and a list
name, values = row # one unpacking statement, two names
values.append(0.9)
name = "id_2"
print(row)
print(name)Both name and values came out of the same unpacking statement, and each was then modified. Only one of those modifications is visible in row. Explain which, and why, and say what unpacking actually gave you.
Hint: Unpacking binds names to the objects that were already in the tuple, it does not build copies of them. Then ask, for each of the two lines that follow, whether it changes an object or repoints a name.
Answer 1:
(7) is the integer 7, because the parentheses are the ordinary grouping parentheses of arithmetic and build nothing. (7,) is a one-item tuple, because the trailing comma is what constructs a tuple.
For t = (10, 20, 30, 40), all three of the reads are valid: t[0] is 10, t[-1] is 40, and t[1:3] is the tuple (20, 30). Reading a tuple is identical to reading a list, and slicing a tuple hands back a tuple.
Neither write is valid. t.append(50) raises AttributeError, because tuples have no append method at all. t[0] = 99 raises TypeError, because item assignment is exactly what immutability forbids.
DON'T rely on parentheses to make a tuple, a single value needs the trailing comma.
Answer 2:
t = t + (50,) is allowed because nothing is mutated. The + builds a brand new tuple from the two operands, and the assignment repoints the name t at that new object. The original tuple is unchanged, it is simply no longer referred to by t, and if no other name holds it, it is discarded. Rebinding a name is always allowed, only changing an object is forbidden.
For row = ("id", [1, 2]), row[1] = [9] tries to change which object sits in slot 1, and that is item assignment on a tuple, so it raises TypeError. row[1].append(9) does not touch the tuple at all, it reads slot 1 to obtain the list and then changes that list. The tuple still points at the same list it always did, so its promise is intact. Immutability freezes the tuple's slots, one level deep, and reaches no further.
DON'T confuse rebinding a name with editing an object, and don't assume a tuple deeply freezes what is inside it.
Answer 3:
a, b, c = p binds a = 1, b = 2, c = 3, because three names match three items. a, b = p raises ValueError: too many values to unpack (expected 2), because Python will not guess which item to discard. a, *b = p binds a = 1 and b = [2, 3], the starred name collecting the remainder as a list.
The swap works because of evaluation order. a, b = b, a first evaluates the entire right side, building the tuple (old_b, old_a) from the current values, and only then assigns those items to the targets on the left. By the time a is overwritten, the old a is already captured inside that tuple, so nothing is lost. Writing it by hand as a = b then b = a fails precisely because it lacks that separation, the first line destroys the old a before the second line can read it.
DON'T unpack into a mismatched number of plain names, and don't reach for a temporary variable to swap.
Answer 4:
row prints ('id_1', [0.5, 0.2, 0.9]). name prints id_2.
Unpacking did not copy anything. name, values = row bound two names to the two objects already sitting in the tuple, so values is not a copy of the list in slot 1, it is that list, and name is the same string object in slot 0.
values.append(0.9) changes that shared list, and because the tuple's slot 1 still points at it, reading row afterwards shows the new item. The tuple never changed, it holds exactly the two objects it always held, one of which now has three entries.
name = "id_2" is an assignment, so it repoints the name name at a different string and leaves the tuple entirely alone. That is why row still shows 'id_1' while name shows id_2.
The two lines look symmetrical and are not. One reaches through a name to change a shared object, the other replaces what a name refers to. A plain assignment such as name = "id_2" only ever repoints a name, whatever it pointed at, and a tuple's immutability gives you no protection against the first, because the first never touches the tuple.
DON'T treat unpacking as a way to take a safe copy out of a tuple, it hands you the very objects the tuple holds.
Assignment
Assignment: Multi-Return Statistics, Parallel Pairing, and a Record That Leaks
Build the following in one file, in this order.
Write describe(values) that returns a tuple (count, total, mean, spread), where spread is the maximum minus the minimum, accumulating count, total, minimum and maximum in a single pass and deriving mean and spread afterwards. Call it on [2.0, 8.0, 4.0, 6.0], unpack all four results, and print them. Then call it again without unpacking and print the whole tuple together with its length.
Then, given the parallel lists names = ["w1", "w2", "w3"] and weights = [0.1, 0.6, 0.3], pair them into a list of (name, weight) tuples and print the pair holding the largest weight, selecting it by comparing the second field rather than the first.
Finally, demonstrate that a tuple's guarantee is one level deep. Build record = ("run_7", [0.1, 0.2]), hand it to a function log_reading(rec, value) that unpacks it and appends value to the unpacked list, call that function twice, and print record before and after. Add a comment stating what the caller's tuple promised and what it did not. Then write the version that keeps the caller safe, and print both results.
Hints:
list(zip(names, weights))builds the(name, weight)pairs, andmax(pairs, key=lambda p: p[1])returns the pair whose second field is largest, where alambdais a one-expression function written inline.- For the last part, remember unpacking hands you the object the tuple holds rather than a copy of it, so the safe version has to construct a new list rather than appending to the one it was given.
Solution:
# One pass accumulates count, total, min and max.
def describe(values):
count = 0
total = 0
lowest = values[0] # seed both with the first value
highest = values[0]
for v in values:
count += 1
total += v
if v < lowest:
lowest = v
if v > highest:
highest = v
mean = total / count # derived after the loop, when the pass is done
spread = highest - lowest
return count, total, mean, spread # one 4-tuple
n, tot, avg, spr = describe([2.0, 8.0, 4.0, 6.0])
print(n, tot, avg, spr) # 4 20.0 5.0 6.0
whole = describe([2.0, 8.0, 4.0, 6.0])
print(whole, len(whole)) # (4, 20.0, 5.0, 6.0) 4names = ["w1", "w2", "w3"]
weights = [0.1, 0.6, 0.3]
# 'zip' walks both lists together and yields one tuple per position.
pairs = list(zip(names, weights))
print(pairs) # [('w1', 0.1), ('w2', 0.6), ('w3', 0.3)]
# 'max' compares whatever the key function returns, here the second field.
top = max(pairs, key=lambda p: p[1])
print(top) # ('w2', 0.6)# LEAKS: unpacking hands over the tuple's own list, and append changes it.
def log_reading(rec, value):
run_id, readings = rec # 'readings' IS the list inside 'rec'
readings.append(value) # so this reaches back into the caller's tuple
return run_id, readings
record = ("run_7", [0.1, 0.2])
print(record) # ('run_7', [0.1, 0.2])
log_reading(record, 0.3)
log_reading(record, 0.4)
print(record) # ('run_7', [0.1, 0.2, 0.3, 0.4])
# The tuple promised that slot 1 would always hold that same list. It never
# promised the list would keep the same contents, and that is the whole gap.
# SAFE: build a new list instead of appending to the one you were handed.
def log_reading_safe(rec, value):
run_id, readings = rec
return run_id, readings + [value] # '+' on lists builds a NEW list
fixed = ("run_7", [0.1, 0.2])
after = log_reading_safe(fixed, 0.3)
print(fixed) # ('run_7', [0.1, 0.2])
print(after) # ('run_7', [0.1, 0.2, 0.3])Read those last two lines together, because the claim lives in the gap between them. after carries the new reading, so the function did its job. fixed prints exactly what it was built with, so the function did its job without touching the tuple it was handed. The leaky version could not have printed those two lines, because there was only ever one list and both names would show it.
The contrast between the two functions is one operator. readings.append(value) mutates the object the caller owns, and readings + [value] constructs a new list and leaves the original alone. Both functions return a tuple, both look like they are producing a new record, and only one of them actually is.
DON'T assume a function that returns a new tuple left its inputs alone, and don't reach for .append on anything you were handed by a caller unless changing the caller's data is the documented job.
Additional Resources
Optional. Nothing later depends on either, they are here if immutability or starred unpacking is something you want more repetitions on.
- YouTube - "Learn Python - Full Course for Beginners [Tutorial]" by freeCodeCamp.org. Watch the "Tuples" chapter for a second pass over immutability, down to the
TypeErrora tuple raises when one of its items is reassigned. - Official docs - Python Language Reference, "Assignment statements", the part describing starred targets in unpacking.
Summary
The tuple looks at first like a list with the brakes on, and in pure mechanics that is fair, you read it the same way and lose the ability to write. But the real lesson is not the missing methods, it is what the missing methods buy you. A value that cannot change is a value you can trust: trust to pass around without defensive copies, trust to use as a fixed key, trust not to be quietly corrupted by code three functions away, as long as nothing mutable sits inside it. Immutability is not a limitation bolted onto sequences, it is a guarantee you sometimes want badly enough to give up mutation for.
Unpacking is the second half of the lesson and, in day-to-day code, the part you will use most. Once you see that the comma is the real operator, that 1, 2, 3 is already a tuple and that the left side of an assignment can name its parts, a lot of Python suddenly reads more cleanly. A coordinate becomes x, y. A swap becomes one honest line. A function that produces several results stops pretending to return many things and is revealed to return one tuple that you take apart at the door. Multiple assignment and tuple unpacking are not two features, they are one idea wearing two hats.
Carry forward the thread that runs through both lessons so far: the difference between changing a thing and building a new one, and the way names point at objects rather than being the objects. A tuple makes that distinction unavoidable, because changing is simply off the table. The next lesson turns to the dictionary, where immutability returns from a new angle, the keys, the things you look entries up by, must never change in a way the dictionary would notice, which is why a tuple can be one and a list cannot.
Cheat Sheet
Part A - Reference Table
| Concept | Syntax | Notes | When to Avoid |
|---|---|---|---|
| Tuple literal | (a, b) or a, b |
commas make it, not parens | when it must grow/shrink |
| One-item tuple | (x,) |
trailing comma required | n/a |
| Read item | t[i], t[-1], t[a:b] |
same as lists | n/a |
| Mutate | t[i] = x |
raises TypeError, tuples are immutable |
always (use a list) |
| Build new | t + (x,), t[a:b] |
returns a new tuple | n/a |
| Unpack | a, b, c = t |
counts must match | mismatched count |
| Starred unpack | first, *rest = t |
rest is a list |
n/a |
| Multiple assign | a, b = 1, 2 |
packs then unpacks | n/a |
| Swap | a, b = b, a |
no temp needed | n/a |
| Multi-return | return x, y |
returns one tuple | n/a |
Part B - Compact Code Block
point = (3, 7) # tuple, commas matter
one = (5,) # one-item needs comma
x = point[0] # read like a list
a, b = point # unpack two names, while it still HAS two
point = point + (9,) # build new, rebind name
first, *rest = (1,2,3,4) # rest is a list [2,3,4]
lo, hi = 1, 9 # multiple assignment
lo, hi = hi, lo # swap, no temp
def ends(v): # multi-return = one tuple
return v[0], v[-1]
head, tail = ends([2,5,1]) # unpack the returned tuple