---
title: Tuples
source: 'https://academia.sh/en/courses/python-data-structures/tuples'
course: 'Data Structures and Functional Tools'
language: en
updated: '2026-08-17T18:10:25+00:00'
license: 'CC BY-SA 4.0'
---

# Tuples

Three of six operations fail on a tuple with an exception, but the operation that mutates an inner item still passes on a tuple; immutability is one layer deep, and in exchange the container gains the capability to be a key, as seen in three of five candidates.

Every measurement in the previous lesson rested on the list being mutable: a
slot could be added, deleted, rearranged. Objects created was zero in most
forms, because the list only rearranged slots.

This lesson repeats the same slots in an **immutable** container. A **tuple**
is also an ordered sequence of slots, but once built, its slots cannot be
rewritten. The question has two countable parts: which operation does
immutability actually make impossible, and what capability does the
container gain in exchange?

## What Immutability Closes Off

The concept of immutability itself is not this course's subject. The
distinction between a mutable and an immutable object was built in the
Programming Fundamentals course; the Software Design and Architecture
Principles course took up immutability as a **design technique** eliminating
shared state. Neither repeats here. What's measured is narrower and more
concrete: when the same operations run on both containers, **which lines
pass, which fail with an exception.**

Where the distinction stands has to be said up front, since the measurement
looks exactly there. A tuple protects **its own slots**. It cannot interfere
with the objects those slots point to — those are separate objects, subject
to their own rules. A tuple's guarantee is therefore not "the contents do not
change" but **"which object I'm looking at does not change."**

- **CO13** — Both containers try all six operations in the **same order**,
  each with a freshly built container from the same values.
- **CO14** — The result column takes one of three values: if it passed,
  whether the result is **the same container** or a **new container**; if
  not, the **class name** of the exception raised.
- **CO15** — "Same container" is an `is` comparison against the reference
  held before the operation.
- **CO16** — Sorting uses a **key function**; no comparison method is added
  to the item class, so counting order does not change.
- **CO17** — The line mutating an inner item never touches the container
  itself; it only writes to a field on the object a slot points to.

```python
"""Same six operations on a list and a tuple: what does immutability actually make impossible."""

COUNTER = {"created": 0}


class Item:
    def __init__(self, value):
        COUNTER["created"] += 1
        self.value = value

    def __repr__(self):
        return f"Item({self.value})"


def reset():
    COUNTER["created"] = 0


def created():
    return COUNTER["created"]


def value(o):
    return o.value


POOL = [Item(i) for i in (3, 1, 4)]


def overwrite_slot(k):
    k[0] = Item(9)
    return k


def append_end(k):
    k.append(Item(9))
    return k


def delete_slot(k):
    del k[0]
    return k


def inplace_concat(k):
    k += type(k)((Item(9),))
    return k


def mutate_inner(k):
    k[0].value = 9
    return k


def sort_new(k):
    return sorted(k, key=value)


OPERATIONS = (("k[0] = new", overwrite_slot), ("k.append(new)", append_end),
              ("del k[0]", delete_slot), ("k += (new,)", inplace_concat),
              ("k[0].value = 9", mutate_inner), ("sorted(k, ...)", sort_new))

print(f"{'operation':<18s}{'in list':<16s}{'in tuple'}")
for label, op in OPERATIONS:
    row = []
    for kind in (list, tuple):
        k = kind(Item(o.value) for o in POOL)
        before = k
        try:
            result = op(k)
            row.append("same container" if result is before else "new container")
        except Exception as e:
            row.append(type(e).__name__)
    print(f"{label:<18s}{row[0]:<16s}{row[1]}")

print()
tup = tuple(POOL)
reset()
bigger = tup + (Item(9),)
print(f"appending to tuple: created {created()} | old slots {len(tup)} -> "
      f"new slots {len(bigger)}")
print(f"bigger is tup -> {bigger is tup} | "
      f"bigger[0] is tup[0] -> {bigger[0] is tup[0]}")

print()
print(f"reading tup[0].value: {tup[0].value}")
tup[0].value = 9
print(f"after mutating the inner item: {tup[0].value} | "
      f"tup[0] is POOL[0] -> {tup[0] is POOL[0]}")
```

```
operation         in list         in tuple
k[0] = new        same container  TypeError
k.append(new)     same container  AttributeError
del k[0]          same container  TypeError
k += (new,)       same container  new container
k[0].value = 9    same container  same container
sorted(k, ...)    new container   new container

appending to tuple: created 1 | old slots 3 -> new slots 4
bigger is tup -> False | bigger[0] is tup[0] -> True

reading tup[0].value: 3
after mutating the inner item: 9 | tup[0] is POOL[0] -> True
```

## Three Rows Fail, Three Pass

Three of the six operations fail on a tuple with an exception, three pass.
The three that fail all tried the same thing: **rewriting, adding, or
deleting a slot.** How they fail differs. Writing to a slot and deleting one
give `TypeError` — these spellings mean something on a tuple too, but it
does not support that meaning. Appending, though, gives `AttributeError`:
there **is no** `append` method on a tuple. The first says "I cannot," the
second says "I know no such thing."

Two of the three passing rows already did not change the container. `sorted`
gives a **new container** on both, since it always returns a new list and
never touches the source. `k += (new,)` sits right in the middle: **same
container** on the list, **new container** on the tuple. The syntax is the
same, the result looks the same, but one extends the existing container and
the other builds a new one and binds the name to it. Because a tuple is
immutable, growing it "in place" is not an extension there, it is a
**rebuild**.

The numbers in the middle give the bill: created is **1**, the appended
item itself. The container grows — three slots to four — but the three old
objects inside it are not recreated. `bigger is tup` is **False**, so the
container really is new; but `bigger[0] is tup[0]` is **True**, so the item
is old. Growing a tuple by appending one item a thousand times means
building a thousand containers, not copying the objects inside it a
thousand times.

The last two lines are this lesson's most critical measurement. `k[0].value
= 9` **passes** on a tuple — without any warning. The tuple protected its
own slot: it still points to the same object, and `tup[0] is POOL[0]`
confirms it. But that object's **inside** changed; the value read was **3**,
then became **9**. This means **immutability is one layer deep**: a
container's guarantee is limited to which object its slots point to, not
the state of those objects.

## What Immutability Buys

A capability opens up in exchange for the three operations closed off. An
object being **hashable** means a fixed **hash value** can be computed from
it, one that stays unchanged for the object's whole lifetime. A container
whose slots can be rewritten cannot make that promise: when it changed, its
hash value would change too, and the object would get lost wherever it had
been placed. A tuple can make that promise — but only if what's inside it
can too.

- **CO18** — Hashability is tested directly: taking the hash value is
  attempted, and the exception's class name is written. **The hash value is
  never printed**; what's measured is whether it could be taken, not the
  value.
- **CO19** — Whether it can be a key is a separate column, tested by
  building a single-entry mapping; the two columns confirm each other.
- **CO20** — In the multiple-return measurement, the function returns
  **already existing** items, so only the cost of the return and the
  unpacking themselves is counted.
- **CO21** — Created count during unpacking is found by subtracting the
  count at return time from the count after.

```python
"""What a tuple gains: being usable as a key and carrying a multiple return value."""

COUNTER = {"created": 0}


class Item:
    def __init__(self, value):
        COUNTER["created"] += 1
        self.value = value

    def __repr__(self):
        return f"Item({self.value})"


def reset():
    COUNTER["created"] = 0


def created():
    return COUNTER["created"]


CANDIDATES = (("(2031, 7)", (2031, 7)), ("[2031, 7]", [2031, 7]),
              ("((2031, 7), (2032, 1))", ((2031, 7), (2032, 1))),
              ("(2031, [7])", (2031, [7])), ("'north'", "north"))

print(f"{'candidate':<24s}{'hashable':<16s}{'can be a key'}")
for label, x in CANDIDATES:
    try:
        hash(x)
        h = "yes"
    except TypeError as e:
        h = type(e).__name__
    try:
        {x: 0}
        a = "yes"
    except TypeError as e:
        a = type(e).__name__
    print(f"{label:<24s}{h:<16s}{a}")

RECORD = {(2031, 7): "slope", (2031, 8): "north", (2032, 1): "summary"}
print()
print(f"dict keyed by tuple: {len(RECORD)} entries | "
      f"RECORD[(2031, 8)] -> {RECORD[(2031, 8)]!r}")

POOL = [Item(i) for i in (3, 1, 4)]


def triple():
    return POOL[0], POOL[1], POOL[2]


reset()
result = triple()
return_created = created()
a, b, c = result
unpack_created = created()
print()
print(f"return container type {type(result).__name__} | slots {len(result)} | "
      f"created on return {return_created} | created on unpack "
      f"{unpack_created - return_created}")
print(f"a is POOL[0] -> {a is POOL[0]} | c is POOL[2] -> {c is POOL[2]}")

reset()
x, y = POOL[0], POOL[1]
x, y = y, x
print(f"swapping two names: created {created()} | "
      f"x is POOL[1] -> {x is POOL[1]}")

reset()
head, *middle = POOL
print(f"starred unpacking: created {created()} | middle is a "
      f"{type(middle).__name__} with {len(middle)} slots | "
      f"middle[0] is POOL[1] -> {middle[0] is POOL[1]}")
```

```
candidate               hashable        can be a key
(2031, 7)               yes             yes
[2031, 7]               TypeError       TypeError
((2031, 7), (2032, 1))  yes             yes
(2031, [7])             TypeError       TypeError
'north'                 yes             yes

dict keyed by tuple: 3 entries | RECORD[(2031, 8)] -> 'north'

return container type tuple | slots 3 | created on return 0 | created on unpack 0
a is POOL[0] -> True | c is POOL[2] -> True
swapping two names: created 0 | x is POOL[1] -> True
starred unpacking: created 0 | middle is a list with 2 slots | middle[0] is POOL[1] -> True
```

## Three of Five Candidates Pass

The table shows hashability looks not at the container but at **the
container as a whole**. A pair of numbers passes, a nested tuple passes too —
because everything inside it is hashable as well. A list fails, as
expected. The real row is the fourth: **a tuple carrying a list fails too**,
even though the container itself is immutable. A tuple's hash value is
computed from the hash values inside it, and if one of them cannot give one,
the tuple cannot either.

This is the second face of the previous section's finding. There,
immutability was one layer deep; here, the language is seen to **know** it.
A tuple gives its guarantee only to the extent its contents give it too, and
when they cannot, it loses the capability to be a key. The two columns
matching is not coincidence: a mapping's condition for being a key is exactly
that a hash value can be taken.

The three measurements below count a tuple's invisible use. When a function
returns three comma-separated values, a **tuple** is what comes out — not a
separate syntax, just the parenthesis-free spelling of building one.
Created on return is **0**, created on unpacking is **0**. The function
packed up three objects and handed them over, the assignment bound three
names to those objects; no copy came out anywhere, confirmed by `a is
POOL[0]`.

Swapping two names is the shortest form of the same mechanism: the right
side builds a tuple, the left side unpacks it, items created **0**. Starred
unpacking adds one detail — the container collecting the remaining slots
is not a tuple, it is a **list**, and its slots still go to the old objects.
A multiple return value is cheap for this reason: what gets carried is not
the objects, it is the links to them.

## How a Key Is Found

That a tuple can be a key was measured; how the key is **found** was not.
This matters, because in the previous lesson's deletion measurement, a
method that looked at equality fell back to identity. There is no such
fallback in a mapping: a key is always searched for by hash value and
equality, never by identity.

- **CO22** — The sought key is built from two variables **at runtime**; the
  key inside the mapping comes from the source text's spelling. That the
  two are separate objects is tested with `is`.
- **CO23** — The **equality** of the hash values is compared; the values
  themselves are never printed.
- **CO24** — Three tuples carrying the same values are built separately in
  a loop; two are equal, and entries remaining in the mapping are counted.

```python
"""A key is found by equality, not identity; the container's type takes part in equality."""

RECORD = {(2031, 7): "slope", (2031, 8): "north"}
YEAR, MONTH = 2031, 7
SOUGHT = (YEAR, MONTH)   # built at runtime, not a literal
FIRST = next(iter(RECORD))

print(f"sought is the dict's key -> {SOUGHT is FIRST}")
print(f"sought == the dict's key -> {SOUGHT == FIRST}")
print(f"hash values equal -> {hash(SOUGHT) == hash(FIRST)}")
print(f"RECORD[SOUGHT] -> {RECORD[SOUGHT]!r}")

print()
print(f"(2031, 7) == [2031, 7] -> {(2031, 7) == [2031, 7]}")
print(f"(2031, 7) == (2031, 7, None) -> {(2031, 7) == (2031, 7, None)}")
for candidate in ((2031, 7), [2031, 7]):
    try:
        found = candidate in RECORD
    except TypeError as e:
        found = type(e).__name__
    print(f"{str(candidate):<12s} in RECORD -> {found}")

print()
TUPLES = [(2031, n) for n in (7, 8, 7)]
print(f"three tuples built | distinct objects: "
      f"{TUPLES[0] is TUPLES[2]} | equal: {TUPLES[0] == TUPLES[2]}")
MAPPING = {t: i for i, t in enumerate(TUPLES)}
print(f"all three used as keys, entries remaining in the mapping: {len(MAPPING)}")
```

```
sought is the dict's key -> False
sought == the dict's key -> True
hash values equal -> True
RECORD[SOUGHT] -> 'slope'

(2031, 7) == [2031, 7] -> False
(2031, 7) == (2031, 7, None) -> False
(2031, 7)    in RECORD -> True
[2031, 7]    in RECORD -> TypeError

three tuples built | distinct objects: False | equal: True
all three used as keys, entries remaining in the mapping: 2
```

The first four lines give the lookup rule: the object is **distinct**, the
value is **equal**, the hashes are **equal**, and the lookup succeeds.
Identity is not what finds a key. This is why a tuple is a cheap key — no
need to hold on to it, building a new one from the same values is enough.

The lines in between show equality **covers the container's type too**. A
list carrying the same two numbers is not equal to a tuple, and no rule
strips the type and looks only at content. A tuple of a different length
is not equal either. The membership test is even stricter: a list is not just
judged unequal, it fails with `TypeError` — a hash value has to be taken
before searching, and that step never happens at all.

The last two lines make equality's result concrete. Three tuples were built
separately; the first and third are distinct objects but equal. With all
three used as keys, **2** entries remain in the mapping: two equal keys
landed in the same place, and the second overwrote the first's value. The
container's identity never entered into it anywhere.

## Summary

- Immutability closes off three operations: writing to a slot, deleting a
  slot, and adding a slot. The first two fail with `TypeError`, the last
  with `AttributeError` — one says "I cannot," the other "I do not know
  that."
- `k += (new,)` on a tuple is not an extension, it is a **rebuild**: the
  container is new (`bigger is tup` → False) but the items are old
  (`bigger[0] is tup[0]` → True), and created is **1**.
- Immutability is **one layer deep**: `k[0].value = 9` still passes on a
  tuple, because the tuple protects its slot, not the inside of the object
  the slot points to.
- The capability a container gains is being usable as a key, and that
  capability **depends on its contents**: three of five candidates pass, and
  a tuple carrying a list fails even though the container itself is
  immutable.
- A multiple return value is a tuple and creates **0** items; unpacking
  creates **0** too, because what gets carried is not objects, it is the
  links to them.

## Next Step

A tuple **could be** a key; but the container that takes a key and reaches a
value hasn't been measured yet. The next lesson looks at the mapping and
pays the first half of the course's fourth debt: when the same two hundred
items are searched for in a list and in a set, how many comparisons happen?
The data is the same, the items are the same, only the container holding
them changes. The same lesson asks a second question too: when a
dictionary's keys are asked for, is what comes back a copy, or a window
that keeps looking at the dictionary?
