Lists and Tuples

Updated

September 8, 2026

Lists and Tuples

A list is a growable sequence. A tuple is a fixed sequence. The boring default is a list when the desk will append, sort, or drop rows, and a tuple when the values are a record (id, table, status) that should not grow a fourth surprise field by accident.

Mental model

Both are ordered, both index from 0, both slice with items[start:stop]. The difference is mutability. tickets.append(t) changes the list. There is no tuple.append. To “change” a tuple you build a new one.

Names are not copies. b = a makes two names for one list. a[:] and list(a) make a shallow copy: a new outer list, same inner objects. Nested lists still alias. copy.deepcopy copies the tree.

A slice of a list is a new list. A slice of a tuple is a new tuple. Either way, the elements are the same objects.

A tuple as a record is positional: ticket[0] is easy to mix up with ticket[1]. Unpack it (id, table, status = ticket) or move to a dataclass when the record grows names.

Worked examples

Case 1: Lists change, tuples do not

Save as list_vs_tuple.py.

# list_vs_tuple.py
def main():
    tickets = [7, 8]
    tickets.append(9)
    tickets[0] = 70
    print(tickets)
    row = (7, 12, "open")
    print(row)
    print(row[0], row[-1])
    try:
        row[0] = 70
    except TypeError as e:
        print(e)


if __name__ == "__main__":
    main()

Run:

uv run python list_vs_tuple.py

Output:

[70, 8, 9]
(7, 12, 'open')
7 open
'tuple' object does not support item assignment

row[-1] is the last field. Use it. Do not mutate a tuple; you cannot.

Case 2: Alias versus copy

# alias_copy.py
def main():
    a = [7, 8, 9]
    alias = a
    copied = a[:]
    alias.append(10)
    copied.append(99)
    print("a     ", a)
    print("alias ", alias)
    print("copied", copied)
    print("alias is a", alias is a)
    print("copied is a", copied is a)


if __name__ == "__main__":
    main()

Run:

uv run python alias_copy.py

Output:

a      [7, 8, 9, 10]
alias  [7, 8, 9, 10]
copied [7, 8, 9, 99]
alias is a True
copied is a False

alias.append changed a. The slice did not. When a function does tickets.append(...), every name that points at that list sees the append.

Case 3: Shallow copy and nested lists

# shallow.py
import copy


def main():
    shift = [[7, 8], [9]]
    shallow = shift[:]
    deep = copy.deepcopy(shift)
    shallow[0].append(70)
    print("shift  ", shift)
    print("shallow", shallow)
    print("deep   ", deep)


if __name__ == "__main__":
    main()

Run:

uv run python shallow.py

Output:

shift   [[7, 8, 70], [9]]
shallow [[7, 8, 70], [9]]
deep    [[7, 8], [9]]

shift[:] copied the outer list only. shallow[0] is the same inner list as shift[0]. deepcopy made new inner lists. Use deepcopy when you have lists of lists (or lists of dicts) and you need a snapshot.

Case 4: Tuple as a record

# ticket_row.py
def label(row):
    ticket_id, table, status = row
    return f"ticket {ticket_id} table {table} ({status})"


def main():
    open_row = (7, 12, "open")
    paid_row = (8, 3, "paid")
    print(label(open_row))
    print(label(paid_row))
    print(open_row + (False,))


if __name__ == "__main__":
    main()

Run:

uv run python ticket_row.py

Output:

ticket 7 table 12 (open)
ticket 8 table 3 (paid)
(7, 12, 'open', False)

Unpacking names the fields for the length of the function. + on tuples concatenates and returns a new tuple; open_row is unchanged. A fourth anonymous False is why records outgrow tuples — you cannot tell what False is. That is the cue for a dataclass.

The trap

Slicing looks like a copy of everything. It is not, once values are mutable.

# slice_alias.py
def main():
    tickets = [{"id": 7, "status": "open"}, {"id": 8, "status": "paid"}]
    window = tickets[:1]
    window[0]["status"] = "void"
    print("window ", window)
    print("tickets", tickets)


if __name__ == "__main__":
    main()

Run:

uv run python slice_alias.py

Output:

window  [{'id': 7, 'status': 'void'}]
tickets [{'id': 7, 'status': 'void'}, {'id': 8, 'status': 'paid'}]

window is a new list of length 1. The dict inside is the same dict as tickets[0]. Changing status through either name changes both. Copy the dict (dict(t) or {**t}) or deepcopy the structure when the window must be independent.

The boring rule

  • List: variable length, same kind of thing (tickets, tables, notes).
  • Tuple: fixed record or a return of two or three values. Unpack it.
  • b = a aliases. a[:] / list(a) shallow-copy. Nested mutables still shared.
  • Do not assign through a slice you thought was a snapshot.
  • When a tuple grows named fields, use a dataclass.

Try this

  1. In alias_copy.py, use list(a) instead of a[:]. Confirm copied is a is still False.
  2. In ticket_row.py, unpack with a wrong number of names (id, table = open_row) and read the ValueError.
  3. Fix slice_alias.py so the window holds {**tickets[0]} (a new dict). Void the window copy only.