Python Collections and Comprehensions

Three orders fit in three variables. Two hundred do not. Once values arrive together, the question changes from “what is this value” to “how do I hold many of them and get back the one I need.” Python answers that with four built-in containers, and picking between them is mostly a matter of which question you will ask: give me the tenth row, keep this record’s field references fixed, look up by customer id, or tell me whether I have seen this id before.

Each code block below continues the same session, so run them in order in one notebook or Python prompt. Outputs were checked with Python 3.12; error messages can be worded differently in other versions.

A single value and a collection of values

A data type defines what an object supports: int supports integer arithmetic, while list supports an ordered collection. A collection is itself one object with a type. A data structure describes how data is organized and accessed; type and structure are not opposing categories. These four common containers give us useful starting choices.

CollectionWritten asYou reach a value byCan change after creation
List[1, 2, 3]PositionYes
Tuple(1, 2, 3)PositionReferences fixed; contained objects may change
Dictionary{"a": 1}KeyYes
Set{1, 2, 3}Membership test; iterate to retrieve elementsYes

Lists and tuples keep the order you put values in, and both allow duplicates. Dictionaries map each key to one value. Sets hold no duplicates and give no positions at all. All four can hold mixed types, and all four are iterable, so a for loop walks through any of them.

Lists keep values in order and let you change them

A list is written in square brackets. Like a string, it is a sequence: it supports positions counted from 0, slices, and the in test. Unlike a string, a list can hold any type of value and can be modified in place.

items = ["Now", "we", "are", "cooking"]
print(len(items), items[0], items[-1])
# 4 Now cooking
print(items[1:3], "we" in items, "this" in items)
# ['we', 'are'] True False

Four methods cover most edits. append() adds one value at the end, insert() places one at a position and shifts the rest, remove() deletes the first occurrence of a value, and pop() removes the value at a position and returns it. Assigning to a position replaces that value.

fruit = ["apple", "banana", "cherry"]
fruit.append("kiwi")
fruit.insert(1, "orange")
print(fruit)
# ['apple', 'orange', 'banana', 'cherry', 'kiwi']
fruit.remove("banana")
removed = fruit.pop(2)
fruit[0] = "mango"
print(fruit, removed)
# ['mango', 'orange', 'kiwi'] cherry
try:
    fruit.remove("plum")
except ValueError as error:
    print("ValueError:", error)
# ValueError: list.remove(x): x not in list

remove() works by value and raises ValueError when the value is absent; pop() works by position and raises IndexError when the position does not exist. Checking with in before removing, or catching the error, keeps a data-cleaning loop from stopping on one unexpected row.

A mutable object is shared, not copied

Assignment binds a name to an object; it does not duplicate the object. Two names for one list are two labels on the same data, so a change through either name is visible through both. The ability to change the object in place is mutability. Aliasing and mutability are separate: names can also share immutable objects.

original = [1, 2]
alias = original
alias.append(3)
print(original, alias, original is alias)
# [1, 2, 3] [1, 2, 3] True
snapshot = original.copy()
snapshot.append(4)
print(original, snapshot)
# [1, 2, 3] [1, 2, 3, 4]
nested = [[1]]
shallow = nested.copy()
shallow[0].append(2)
print(nested, shallow, nested is shallow)
# [[1, 2]] [[1, 2]] False
record = ("A-17", ["paid"])
record[1].append("reviewed")
print(record)
# ('A-17', ['paid', 'reviewed'])

copy() creates a separate outer list: appending or replacing an outer slot stays local to that list. It is a shallow copy, so inner objects remain shared. Strings and integers cannot change in place. A tuple fixes its item references, but a list inside a tuple can still change; making a tuple does not recursively freeze its contents.

Tuples fix the field references of a record

A tuple is an immutable sequence, written in parentheses. Use a list when the number of items grows and shrinks, and a tuple when the positions mean fixed fields, as in one row of data. Unpacking assigns the fields to names in one step.

player = ("Alice", 20, "Guard")
name, age, position = player
print(name, age, position, len(player))
# Alice 20 Guard 3
try:
    player[2] = "Forward"
except TypeError as error:
    print("TypeError:", error)
# TypeError: 'tuple' object does not support item assignment
try:
    first, second = player
except ValueError as error:
    print("ValueError:", error)
# ValueError: too many values to unpack (expected 2)
print(type((1,)), type((1)))
# <class 'tuple'> <class 'int'>

The last line is a syntax detail worth knowing early: a nonempty tuple is formed by commas; the empty tuple is (), so a one-item tuple is written (1,) while (1) is just the number in brackets. Without a starred target such as first, *rest = player, unpacking requires the counts to match, which is why the two-name attempt failed on a three-field record.

A list of tuples is the plainest table: the list is the collection of rows, each tuple is one row. A loop can unpack each row directly, and two indexes reach a single field.

players = [("Alice", 20, "Guard"), ("Beth", 22, "Forward"), ("Cara", 21, "Guard")]
for name, age, position in players:
    print(f"{name:<6}{age:>3}  {position}")
# Alice  20  Guard
# Beth   22  Forward
# Cara   21  Guard
print(players[1][0], players[1][2])
# Beth Forward

Dictionaries answer “which value belongs to this key”

Finding Beth’s position in a list of tuples means scanning rows until her name appears. A dictionary stores key-value pairs and goes straight to the value for a key. Write it with braces and colons, or build it with dict().

zoo = {1: "lion", 2: "zebra", 3: "elephant"}
zoo[4] = "crocodile"
print(zoo)
# {1: 'lion', 2: 'zebra', 3: 'elephant', 4: 'crocodile'}
print(zoo[2], 2 in zoo, "lion" in zoo, zoo.get(9, "missing"))
# zebra True False missing
try:
    zoo[0]
except KeyError as error:
    print("KeyError:", error)
# KeyError: 0

Lookup uses keys, not positions. zoo[0] failed because no key 0 exists, not because there is no zeroth item, and in tests keys rather than values. When a missing key is an ordinary case rather than a defect, get() returns a fallback instead of raising KeyError.

try:
    {[1, 2]: "invalid"}
except TypeError as error:
    print("TypeError:", error)
# TypeError: unhashable type: 'list'
print(list({"b": 1, "a": 2, "c": 3}))
# ['b', 'a', 'c']

Keys must be hashable: their hash stays stable and equal keys have equal hashes. Built-in strings and numbers qualify; a tuple qualifies only if all its items do. A tuple containing a list is unhashable despite being immutable itself. Custom objects can be mutable and hashable, so the two properties are not synonyms. Since Python 3.7 a dictionary keeps the insertion order of its keys, as the second line shows, so the same insertion history produces the same iteration order. An unordered input does not become reproducible merely by entering a dictionary. Do not read that as sorting; if you need alphabetical or numeric order, sort explicitly.

A fallback from get applies only to an absent key: {“x”: None}.get(“x”, 0) returns None. Equal numeric keys also share an entry: 1, 1.0, and True are not three distinct keys. Normalize identifier types deliberately before using keys to deduplicate.

Grouping rows by a field is the pattern that makes dictionaries worth learning early. setdefault() supplies an empty list the first time a key appears, so the loop body stays one line, and items() walks keys and values together.

by_position = {}
for name, age, position in players:
    by_position.setdefault(position, []).append((name, age))
print(by_position)
# {'Guard': [('Alice', 20), ('Cara', 21)], 'Forward': [('Beth', 22)]}
for position, members in by_position.items():
    print(f"{position:<8}{len(members)}")
# Guard   2
# Forward 1

Iterating a dictionary directly yields its keys; values() yields the values and items() yields key-value pairs. The methods keys(), values(), and items() return live views, not snapshots. Adding or deleting keys during iteration may raise RuntimeError or miss entries; do not rely on an error being raised. Replacing an existing value without changing keys is different. Iterate a separate key list such as list(zoo) when adding or deleting keys.

Sets answer “have I seen this already”

A set holds unique values with no positions. Building one from a sequence discards duplicates, which is a direct way to count distinct hashable values. Note the empty-set trap: {} creates a dictionary, so an empty set needs set().

ids = [11, 12, 12, 13, 11]
unique = set(ids)
print(sorted(unique), len(ids), len(unique))
# [11, 12, 13] 5 3
print(type({}), type(set()))
# <class 'dict'> <class 'set'>
try:
    unique[0]
except TypeError as error:
    print("TypeError:", error)
# TypeError: 'set' object is not subscriptable

Sets compare collections with operators that read like their meaning: & keeps what is in both, | keeps everything, - keeps what only the left side has, and ^ keeps what exactly one side has. Union, intersection, and symmetric difference give the same answer if you swap the operands; difference does not.

paid = {11, 12}
shipped = {12, 13}
print(sorted(paid & shipped), sorted(paid | shipped))
# [12] [11, 12, 13]
print(sorted(paid - shipped), sorted(shipped - paid), sorted(paid ^ shipped))
# [11] [13] [11, 13]

Set elements must be hashable for the same reason dictionary keys must be, so tuples must themselves contain only hashable items; lists cannot be elements. Set membership uses hashing and is typically fast on average, though collisions and equality checks can increase the work, which is why a “have I seen this id” check uses a set even when the ids also live in a list. Sets promise no display or insertion order, including sets of integers; sort before displaying when the order matters.

Comprehensions build one collection from another

Creating an empty list, looping, and appending is a pattern common enough to have its own syntax. A list comprehension states the result first and the source second, and an if at the end keeps only some items. The same shape with braces builds a dictionary or a set.

codes = ["a-1", "b-2", "c-3"]
print([code.upper() for code in codes])
# ['A-1', 'B-2', 'C-3']
print([n for n in range(10) if n % 3 == 0])
# [0, 3, 6, 9]
print({code: len(code) for code in codes})
# {'a-1': 3, 'b-2': 3, 'c-3': 3}
print(sorted({code[0] for code in codes}))
# ['a', 'b', 'c']

A comprehension can draw from two sources, which replaces a nested loop. The pairs below are every combination with the second value not below the first, and the second comprehension consumes those tuples by unpacking them.

dominoes = [(i, j) for i in range(3) for j in range(i, 3)]
print(dominoes)
# [(0, 0), (0, 1), (0, 2), (1, 1), (1, 2), (2, 2)]
print(dominoes[4][1], [i + j for i, j in dominoes])
# 2 [0, 1, 2, 2, 3, 4]

A comprehension can avoid some overhead of an explicit append loop, but speed depends on the expression, interpreter, and workload. Compare equivalent code on the same input with repeated timing measurements before changing working code for speed. Compact syntax alone does not prove better performance.

Readability decides more often than speed. A comprehension is clear when it maps or filters in one step. Once it needs several conditions, a nested comprehension, or work that has nothing to do with building the result, an ordinary loop is easier to read and to debug. For a very large source, a generator expression written with parentheses produces values one at a time instead of building the whole list in memory. It is consumed during iteration; a second pass does not restart it.

Choosing a structure, then combining them

The question you askStructure that answers it
What is in row 10, and can rows be added later?List
Which field references should stay fixed in this record?Tuple
What value belongs to this id, name, or status?Dictionary
Have I seen this value, and how many distinct ones are there?Set

Real work combines them. The rows below arrive as a list of tuples with one duplicate. A set records which ids have been seen, a dictionary accumulates a total per status, and a comprehension formats the result.

rows = [("A-17", "paid", 5000.0), ("B-02", "pending", 412.5), ("C-31", "paid", 980.0), ("A-17", "paid", 5000.0)]
seen = set()
unique_rows = []
for order_id, status, amount in rows:
    if order_id in seen:
        continue
    seen.add(order_id)
    unique_rows.append((order_id, status, amount))
totals = {}
for order_id, status, amount in unique_rows:
    totals[status] = totals.get(status, 0.0) + amount
print(len(rows), len(unique_rows))
# 4 3
print(totals)
# {'paid': 5980.0, 'pending': 412.5}
print([f"{status}: {total:,.2f}" for status, total in totals.items()])
# ['paid: 5,980.00', 'pending: 412.50']

Each container does the job it is suited to: the list preserves arrival order, the tuples keep each row’s fields together, the set provides the membership check, and the dictionary groups by status. Deduplicating by id assumes the repeated row is genuinely the same order rather than a second order sharing an id, which is a decision about the data and not something the container can settle. This code keeps the first occurrence and silently ignores later values for that id, including changed status or amount. Use it only when that policy matches the source; conflicting updates need a separate validation or version rule. The fixture assumes valid three-field rows and finite amounts in one currency; floats here illustrate grouping, not exact currency accounting.

Practice

1. A colleague writes numbers = [1, 2, 3], then backup = numbers, then numbers.append(4), and is surprised that backup now has four items. Explain the result and fix it.

Solution

backup = numbers binds a second name to the same list, so both names show every change. A copy makes an independent list:

numbers = [1, 2, 3]
backup = numbers.copy()
numbers.append(4)
print(numbers, backup)
# [1, 2, 3, 4] [1, 2, 3]

list(numbers) and numbers[:] also copy. All three are shallow, so lists stored inside would still be shared.

2. Given people = [("Alice", "Guard"), ("Beth", "Forward"), ("Cara", "Guard")], count how many people hold each position. Then say why the list of tuples alone is a poor structure for repeated lookups by position.

Solution
people = [("Alice", "Guard"), ("Beth", "Forward"), ("Cara", "Guard")]
counts = {}
for name, position in people:
    counts[position] = counts.get(position, 0) + 1
print(counts)
# {'Guard': 2, 'Forward': 1}

Answering “how many people play Guard” from the list means scanning every row each time, and the work grows with the number of rows. A dictionary keyed by position goes straight to the answer, which is why the grouping step is worth doing once when the same question will be asked repeatedly.

3. Remove duplicates from ids = [13, 11, 12, 11, 13] while keeping the order in which each id first appeared. Explain why set(ids) alone is not enough.

Solution

A set discards duplicates but keeps no order, so it cannot report which id came first.

ids = [13, 11, 12, 11, 13]
print(list(dict.fromkeys(ids)))
# [13, 11, 12]
print(sorted(set(ids)))
# [11, 12, 13]

dict.fromkeys() preserves first-seen order because a dictionary keeps insertion order. sorted(set(ids)) gives numeric order instead, which differs from first-seen order here; choose the one whose ordering you actually want.


Discover more from Insightful Data Lab

Subscribe to get the latest posts sent to your email.

Similar Posts

Questions, corrections, or additional insights?

This site uses Akismet to reduce spam. Learn how your comment data is processed.