A tuple looks like a list with parentheses, but every operation on it has a small twist: the original tuple cannot change. Concatenation builds a new tuple, slicing copies into a new tuple, and the only methods available are count() and index(). This lesson walks through +, *, in, == and ordering, slicing, iteration, the two tuple methods, nested access, and the common pattern of building a list of tuples to represent product records.
The + operator joins two tuples and returns a brand-new tuple. The original tuples are left alone, because tuples are immutable. You cannot modify a tuple in place at all, so there is no in-place equivalent of extend() or += that mutates the original.
combined is a fresh tuple that holds references to the same string objects from both sources. cart_a and cart_b are unchanged, because no tuple can ever be changed after it is created.
Both operands have to be tuples. Mixing a tuple with a list or a string raises TypeError:
If you need to mix sides, convert one of them first: cart + tuple(extra).
Cost: t1 + t2 allocates a new tuple of length len(t1) + len(t2) and copies every reference from both sides. That is O(n + m). It looks cheap on small tuples, but inside a loop the cost compounds.
The compound assignment t += other exists, but on a tuple it does not mutate. It rebinds the name to a brand-new tuple, leaving any other names that pointed at the original tuple still looking at the original.
For lists, += mutates the underlying list and both names would see the change. For tuples, += is just a shortcut for cart = cart + ("HDMI Cable",), which builds a new tuple and points only cart at it. alias keeps the original two-item tuple.
A common beginner pattern that grows a tuple in a loop is slow for exactly this reason. Every all_items = all_items + batch line builds an entirely new tuple. For a thousand batches, that is a thousand allocations, each one larger than the last, and the total work grows quadratically with the number of iterations.
Cost: Do not grow a tuple in a loop with + or +=. If you are accumulating values, collect them in a list with list.append, then convert to a tuple at the end with tuple(my_list). Lists are designed for in-place growth; tuples are not.
The * operator on a tuple builds a new tuple by repeating its contents a given number of times. The shape mirrors what * does on lists.
These are common e-commerce shapes: a divider row of dashes, an RGB color of pure black, an empty star-rating row. You can combine * with +, and the usual precedence applies: * runs before +.
A few small rules. Multiplying by 0 or any negative number gives an empty tuple. The multiplier has to be an integer; a float raises TypeError:
Tuples avoid one of the classic list traps. With lists, [[]] * 3 produces three references to the same inner list, so mutating one row appears to mutate all three. Tuples cannot be mutated, so the shared-reference issue cannot cause that kind of surprise. The references are still shared, but you cannot change the things they point to.
Cost: t * n allocates a new tuple of length len(t) * n and copies references. The references all point to the same underlying objects, which is fast and safe for tuples because nothing about a tuple can be changed after creation.
The in operator asks whether a value is somewhere inside the tuple, returning True or False. not in is the inverse.
Comparison uses == under the hood, so it finds values that are equal, not just objects that are identical in memory. For numeric tuples, that means 14.99 in prices works exactly as a beginner would expect.
A typical use is gating a piece of logic on whether a product category appears in an allow-list:
This is one of the most readable uses of a tuple in real code. A fixed allow-list, comparison-based lookup, no method calls.
Cost: x in some_tuple is O(n). Python walks the tuple from left to right and compares each element with == until it finds a match or runs off the end. For a five-item allow-list this is invisible; for a million-item tuple it is slow. If you are checking the same collection many times, build a set once and check membership against the set. Sets give O(1) membership on average.
The left-to-right scan also means in returns as soon as it finds a match. Checking for an item near the front is cheap; checking for one that is not present is the worst case, because Python has to look at every element to be sure.
Two tuples are equal (==) when they have the same length and every pair of corresponding items is equal. Order matters, and the comparison runs recursively through nested tuples.
Only the first pair is equal. The second has the same items in different order. The third has different lengths. The fourth differs at the last nested element.
The ordering operators <, <=, >, and >= use lexicographic comparison, the same rule a dictionary uses to order words. Python walks both tuples in parallel and compares items at matching positions. The first pair that differs decides the result.
In the first line, positions 0 and 1 match, then 3 < 4 at position 2 settles it. In the second, 2 < 3 at position 1 decides things before the loop even reaches the third item. In the third, the tuples match up to where the shorter one ends, and the shorter tuple is considered "less than" the longer one. That is the same rule that makes "go" < "goal" for strings.
All items being compared have to be comparable types. Comparing a tuple of numbers with a tuple of strings raises TypeError as soon as Python hits the first mismatched pair. Within the same tuple, mixed types are fine for == (they just compare unequal); they only become a problem for ordering when the comparator actually has to decide which is smaller.
A practical e-commerce use is sorting product records. A list of (price, name) tuples sorts cleanly by price first, then alphabetically by name on ties, because tuple comparison naturally handles both keys in one pass.
The two products that share a price of 29.99 get a stable secondary sort by name, because Python's tuple comparison moves on to the next element when the first ones are equal. No custom key function needed.
Slicing on a tuple has the same shape as slicing on a list: t[start:stop], t[start:stop:step], with the same defaults and the same half-open stop. The difference is the return type: slicing a tuple returns a new tuple, not a list.
The stop index is exclusive, so products[0:2] covers indices 0 and 1. Omitting start defaults to 0 and omitting stop defaults to the tuple length, exactly as in lists.
Out-of-range bounds are lenient, just like with lists. No IndexError, just whatever the tuple has in that range:
Negative indices count from the end. products[-2:] gives the last two items, products[:-1] gives everything except the last. Combining a step with a negative direction gives the reversal idiom you already know from lists:
products[::-1] walks the tuple from end to start with a negative step. products[::2] walks forward but skips every other element. All four results are tuples, never lists.
t[:] on a TupleFor lists, cart[:] is the standard way to make a shallow copy. For tuples, the same expression behaves differently because of immutability. Since a tuple cannot be changed after creation, Python is free to return the original tuple itself when you ask for a "copy" via t[:]. CPython does this as an optimization. The two names just point at the same object.
Both products[:] and tuple(products) return the exact same object as products. The is check confirms identity, not just equality. With a list, cart[:] would return a new list object every time. With a tuple, there is no observable difference between "the original" and "a copy", so Python skips the allocation. This is a CPython optimization. Other Python implementations are allowed to allocate a new tuple if they choose, but the behavior you can rely on is that the copy has the same contents and the same == result.
Slicing that produces a strict subset always returns a new tuple. The optimization only applies when the slice covers the entire tuple.
Cost: A non-full slice copies stop - start references into a fresh tuple, which is O(k) in time and memory. The full-tuple slice t[:] may return the original tuple itself (CPython optimization), which is effectively free. Either way, the contents inside the tuple are never deep-copied; only the references are.
+, *, and Slicing All Have in CommonEverything that produces a tuple result, whether through concatenation, repetition, or slicing, produces a brand-new tuple. Compare that to the list world, where some operations build new lists and others mutate the original in place. With tuples, there is no in-place option at all, so the picture is simpler.
The diagram shows what every tuple operation does: the originals stay put, and a new tuple is produced. With lists, the equivalent diagram would have two branches at every operation, one for the build-new path (+, slicing) and one for the mutate-in-place path (extend, +=, sort). Tuples remove the second branch entirely. The single exception is t[:] and tuple(t), where CPython may return the same object because there is nothing to gain from copying an immutable structure.
A for loop walks a tuple one item at a time. The mechanics are the same as for lists or any other iterable.
When you need both the index and the value, enumerate() works the same way it does on a list:
enumerate() returns an iterable of (index, value) pairs. You may have spotted that each pair is itself a tuple. Iterating over a tuple of tuples is one of the most common shapes in everyday Python, and we look at that pattern in detail at the end of this lesson.
A tuple is so minimal that it has only two methods. That is not a typo. Lists have append, extend, insert, pop, remove, sort, reverse, and more, because lists can be modified. Tuples cannot be modified, so most of those operations have nowhere to land. The two methods that survive are count() and index(), both of which only read the tuple.
count(value) returns how many times value appears in the tuple. It uses == for comparison, so it counts items that compare equal, not just identical objects. A value that never appears returns 0, never raises.
Cost: count() walks the entire tuple, so it is O(n) regardless of how many matches it finds. It cannot stop early, because it has to count every match. If you need counts for several values, building a collections.Counter once is more efficient than calling count() repeatedly.
index(value) returns the position of the first occurrence of value in the tuple. If the value is not present, it raises ValueError.
Cable appears twice, at index 1 and index 4. index() returns the first occurrence, which is 1. To find later occurrences, pass a start argument:
The signature is index(value, start=0, stop=len(tuple)). The search runs over the slice [start:stop], and the returned index is the absolute position in the original tuple, not an offset into the slice.
When the value is missing, index() raises ValueError. This is unlike in, which returns False for missing values.
The conventional pattern is to guard with in first if you are not sure the value is there:
There is a cost trade-off worth knowing about. The if target in products check walks the tuple once, and products.index(target) walks it again. Two scans on the worst case. For small tuples this is fine. For large tuples where the value is likely missing, it can be cheaper to try index() and catch the exception:
This is the classic "look before you leap" versus "easier to ask forgiveness than permission" choice, and both styles are common in Python. Use whichever fits the surrounding code.
Cost: index() is O(n) in the worst case, because Python has to scan until it finds the value or runs off the end. It does return as soon as it finds a match, so a value near the front is cheap.
A tuple can hold other tuples. Reading values from a nested tuple uses chained indexing: one set of brackets per layer.
order[2] returns the inner tuple (29.99, "USD"). Adding [0] reads the first element of that inner tuple. The brackets read left to right, so order[2][0] is "the first element of the third element of order".
A nested tuple is common in coordinate data. A GPS location can be modeled as a tuple of latitude and longitude paired with a name:
The same pattern works for arbitrarily deep nesting, but the readability falls off fast once you get past two levels. If you find yourself writing data[0][2][1][3], the data structure probably wants a different shape, or a namedtuple.
Tuples are most useful when each one represents a small, fixed record with parts of different types, and you have many such records. A product can be (name, category, price, stock). An order line can be (product_name, quantity). A coordinate can be (lat, lon). A list of these tuples is one of the most common shapes in everyday Python.
The list grows and shrinks as products are added and removed; the tuples inside it represent individual records that do not need to change.
Reading from such a list typically uses indexing inside the loop. To compute the total stock value across the catalog, multiply price by stock for each record:
Accessing fields by index works, but product[2] and product[3] are not self-documenting. A reader has to remember which position holds the price. A cleaner pattern uses tuple unpacking in the loop variable. For now, the index-based style is enough to see how a list of tuples behaves.
A filter is just as direct. Print every product priced under twenty dollars by reading the price at index 2:
This is the pattern you will see most often when tuples appear in real Python code: a list of homogeneous records, each one a small tuple, walked with a for loop, fields read by index or by unpacking.
The output may surprise someone who expects += to behave the way it does on lists. For lists, palette += (color,) would mutate the underlying list, and original would hold three items afterward. For tuples, += is rebinding, not mutation. The function builds a new tuple and rebinds its local palette to that new tuple. original outside the function still points at the unchanged two-item tuple. Tuples cannot be modified, so any expression that looks like it is changing one is really building a new tuple and rebinding a name.
Fix: return the new tuple and let the caller reassign.
The function returns a new tuple, and the caller overwrites the name original with that returned value.
10 quizzes