From single values to collections

In Part 2 you worked with single values: a number stored in a variable, a piece of text, a boolean flag. That covers a single measurement, but real data work almost never deals with one value at a time. You have a column of prices, a list of customer names, a set of unique product codes, or a row of data where each field has a label. Python gives you four built-in types for grouping values together: lists, tuples, sets and dictionaries. Each one has different rules about order, duplicates and whether you can change it after creating it, and those rules are exactly what make each one useful for a different job.

This article builds intuition for each type one at a time, then compares them directly so you know which one to reach for. We will not use loops explicitly yet, since those are the subject of Part 4, but you will see a few simple for-loops used only to inspect a collection, because it is hard to talk about collections without showing what is inside them.

  • List: ordered, changeable, allows duplicates. Your general-purpose sequence.
  • Tuple: ordered, unchangeable, allows duplicates. A fixed record you do not want altered.
  • Set: unordered, changeable, no duplicates. For uniqueness and fast membership checks.
  • Dictionary: keeps insertion order, changeable, no duplicate keys. For labelled lookups, like a row with named fields.

Lists: ordered, changeable collections

A list is the closest thing Python has to a general-purpose container. Think of it as a shopping list: the items are in a specific order, you can add or remove items, and you can have the same item appear twice. You create one with square brackets, separating items with commas.

🐍Python
temperatures = [18.5, 21.0, 19.8, 23.4, 20.1]
print(temperatures)
print(type(temperatures))

Running this prints the list exactly as written, and type(temperatures) reports list. Each item inside keeps its position, starting at index 0, which is the same zero-based counting you saw with string indexing in Part 2 (a list behaves like a string of values rather than characters).

🐍Python
temperatures = [18.5, 21.0, 19.8, 23.4, 20.1]

first = temperatures[0]      # 18.5
last = temperatures[-1]      # 20.1
middle_three = temperatures[1:4]  # [21.0, 19.8, 23.4]

print(first, last)
print(middle_three)

temperatures[0] gets the first element, temperatures[-1] gets the last one by counting from the end, and temperatures[1:4] is a slice: it takes elements starting at index 1 up to, but not including, index 4. Slicing a list always returns a new list, even if it contains a single item or no items at all.

Lists can change after creation

This is the property that makes a list a list rather than a tuple. You can add items, remove items, replace items, and reorder items, all without creating a new variable.

🐍Python
temperatures = [18.5, 21.0, 19.8, 23.4, 20.1]

temperatures.append(22.0)      # add to the end
print(temperatures)

temperatures.insert(0, 17.2)   # insert at a specific position
print(temperatures)

temperatures.remove(19.8)      # remove the first matching value
print(temperatures)

last_value = temperatures.pop()  # remove and return the last item
print(last_value, temperatures)

temperatures.sort()
print(temperatures)

temperatures.sort(reverse=True)
print(temperatures)

Each of these methods changes the list in place and returns None, except pop, which returns the item it removed. A common mistake is writing something like temperatures = temperatures.sort(), expecting to capture a sorted list. sort() returns None, so that line would overwrite your list with nothing useful. If you want a new, sorted list without touching the original, use the built-in sorted() function instead, which returns a fresh list: sorted_temps = sorted(temperatures).

You can also combine lists, check length, and check membership, all of which come up constantly when working with columns of data.

🐍Python
morning = [18.5, 21.0, 19.8]
afternoon = [23.4, 20.1, 22.0]

all_readings = morning + afternoon
print(all_readings)
print(len(all_readings))
print(23.4 in all_readings)   # True
print(average := sum(all_readings) / len(all_readings))

sum() and len() are built-in functions that work on any list of numbers, and the in keyword checks membership, returning a boolean. The walrus operator (:=) used above is just a compact way to assign and print a value in one line; you do not need it, and writing average = sum(all_readings) / len(all_readings) on its own line works exactly the same way and is usually clearer.

A subtle trap: copying versus aliasing

Because lists are mutable, assigning one list variable to another does not create a second, independent list. It creates a second name pointing at the same list in memory.

🐍Python
original = [1, 2, 3]
alias = original       # alias points to the same list
alias.append(4)

print(original)   # [1, 2, 3, 4]  -- changed too!
print(alias)       # [1, 2, 3, 4]

# to get an independent copy:
copy = original.copy()   # or list(original)
copy.append(5)
print(original)   # unchanged: [1, 2, 3, 4]
print(copy)         # [1, 2, 3, 4, 5]

This behaviour surprises almost everyone the first time they meet it. If you need an independent copy of a list, use .copy() or list(original) rather than a plain assignment. We will revisit this idea of shared references in Part 5, because it also affects how lists behave when passed into functions.

Tuples: ordered, unchangeable collections

A tuple looks almost identical to a list, but once created, it cannot be changed. You write it with parentheses instead of square brackets.

🐍Python
location = (51.5074, -0.1278)   # latitude, longitude of a point
print(location)
print(type(location))
print(location[0])  # 51.5074

# this would raise an error if you tried it:
# location[0] = 52.0   -> TypeError: 'tuple' object does not support item assignment

Indexing and slicing work exactly the same way as with lists, because both are ordered sequences. The difference is that tuples have no append, insert, remove or sort methods, because those would all require changing the tuple after creation. If you try to assign to an index, Python raises a TypeError immediately, which is the point: a tuple is a promise that this value will not change underneath you.

Immutability is not a limitation to work around, it is a feature to use deliberately. A tuple is a good fit whenever you are representing a fixed record whose fields belong together and should not be edited individually, such as a coordinate pair, an RGB colour (255, 0, 0), or a single row of fixed structure returned from a database query. Because tuples cannot change, Python can also use them as dictionary keys and as members of a set, something lists are not allowed to do, as you will see shortly.

Unpacking: a feature you will use constantly

Tuples (and lists) support unpacking, where you assign each element to its own variable in one line. This comes up so often in data work that it is worth learning now.

🐍Python
location = (51.5074, -0.1278)
latitude, longitude = location
print(latitude)   # 51.5074
print(longitude)  # -0.1278

# swapping two variables without a temporary one:
a, b = 1, 2
a, b = b, a
print(a, b)  # 2 1

On the right-hand side, 1, 2 is actually a tuple created without even writing the parentheses; Python allows this shorthand, which is why a, b = b, a works as a clean swap. You will see this pattern again when looping over dictionaries later in this article, and again in Part 4 when looping over pairs of values.

The one-element tuple gotcha

Because parentheses are also used for grouping expressions, Python needs a way to tell (5) the number from (5,) the tuple. The rule is that a trailing comma, not the parentheses, is what makes a tuple.

🐍Python
not_a_tuple = (5)
print(type(not_a_tuple))   # <class 'int'>

is_a_tuple = (5,)
print(type(is_a_tuple))    # <class 'tuple'>

This trips up almost everyone at least once. If you ever find a function returning an int when you expected a one-item tuple, check for a missing comma.

Sets: unordered collections of unique items

A set is built for one job: keeping track of unique items and testing membership quickly. Duplicates are automatically discarded, and there is no guaranteed order, so you cannot index into a set with set[0].

🐍Python
visitor_countries = ["UK", "France", "UK", "Germany", "France", "Spain"]
unique_countries = set(visitor_countries)
print(unique_countries)
print(len(unique_countries))  # 4, duplicates removed

Converting a list to a set is one of the most common ways to de-duplicate data in Python. Note that the printed order of a set may not match the order you added items; sets are organised for fast lookup, not for display, so never rely on their order.

To create an empty set you must use set(), not {}, because {} already means an empty dictionary, which we cover next.

🐍Python
empty_set = set()
empty_dict = {}
print(type(empty_set), type(empty_dict))</>

Sets support the mathematical operations you might remember from a Venn diagram: union (everything in either set), intersection (only what is in both), and difference (what is in one but not the other). These are genuinely useful, not just theoretical.

🐍Python
week1_users = {"alice", "bob", "carla", "dinesh"}
week2_users = {"bob", "carla", "elin", "farah"}

returning_users = week1_users & week2_users   # intersection
all_users = week1_users | week2_users          # union
only_week1 = week1_users - week2_users          # difference
new_in_week2 = week2_users - week1_users

print("returning:", returning_users)
print("all users:", all_users)
print("left after week 1:", only_week1)
print("new in week 2:", new_in_week2)

Here & is intersection, | is union, and - is difference, matching the mathematical symbols for set operations. The same results are available as methods: week1_users.intersection(week2_users), week1_users.union(week2_users), and week1_users.difference(week2_users), which read a little more clearly if you prefer words over symbols.

Sets are also changeable: you can add() a single item or update() with several, and remove() or discard() an item. The difference between remove and discard is that remove raises an error if the item is not present, while discard quietly does nothing.

🐍Python
tags = {"python", "beginner"}
tags.add("tutorial")
tags.update(["data", "python"])   # "python" is already there, ignored
print(tags)

tags.discard("missing-tag")  # no error, nothing happens
tags.remove("beginner")
print(tags)

Membership testing with in is where sets really shine. Checking whether a value is in a set is far faster than checking whether it is in a list, especially as the collection grows, because a set is organised internally for quick lookup rather than being scanned item by item. For now, remember the practical rule: if you only care about uniqueness and fast membership checks, and you do not need order or duplicates, reach for a set.

Dictionaries: key-value mappings

A dictionary stores pairs of keys and values, like a lookup table or a row of labelled fields. Instead of finding a value by its numeric position, as in a list, you find it by its key, which is usually a string.

🐍Python
product = {
    "name": "Wireless Mouse",
    "price": 19.99,
    "in_stock": True,
    "tags": ["electronics", "accessories"],
}

print(product["name"])
print(product["price"])
print(type(product))</>

product["name"] retrieves the value associated with the key "name". Notice that a dictionary's values can be any type at all, including, as shown with "tags", another collection. Keys, on the other hand, must be of a hashable (effectively, immutable) type: strings, numbers, booleans, and tuples all work as keys, but lists and other dictionaries do not, because they can change and Python needs a key's identity to stay fixed.

Accessing a key that does not exist with square brackets raises a KeyError, which stops your program unless you handle it (error handling is the subject of Part 8). For now, the safer way to read a value you are not sure exists is the .get() method, which returns None, or a default value you choose, instead of crashing.

🐍Python
product = {"name": "Wireless Mouse", "price": 19.99}

print(product.get("price"))             # 19.99
print(product.get("discount"))          # None, no error
print(product.get("discount", 0.0))      # 0.0, our chosen default</>

Dictionaries are changeable

You can add a new key, update an existing one, or remove one, all without recreating the dictionary.

🐍Python
product = {"name": "Wireless Mouse", "price": 19.99}

product["price"] = 17.99       # update an existing key
product["in_stock"] = True      # add a new key
del product["name"]              # remove a key

print(product)

removed_value = product.pop("price")
print(removed_value, product)</>

Assigning to a key that already exists overwrites its value; assigning to a key that does not exist yet creates it. del removes a key outright, and pop removes a key while also returning its value, similar to how pop worked on lists.

Looping over keys, values and pairs

Looping is formally covered in Part 4, but it is worth seeing the three most common patterns now, because they are specific to dictionaries and you will meet them immediately once loops are introduced.

🐍Python
prices = {"mouse": 19.99, "keyboard": 45.00, "monitor": 180.00}

for name in prices:                  # loops over keys by default
    print(name)

for price in prices.values():         # loops over values
    print(price)

for name, price in prices.items():    # loops over key-value pairs
    print(name, "costs", price)</>

prices.items() returns each pair as a tuple, which is why name, price = ... unpacking works directly inside the for loop, the same unpacking trick shown earlier with tuples. Since Python 3.7, dictionaries reliably preserve the order in which keys were inserted, so looping over prices will visit "mouse", "keyboard", "monitor" in that order, not some arbitrary order. This was not guaranteed in older Python versions, so if you ever see code or advice written for Python 3.6 or earlier treating dictionary order as unpredictable, that is why.

A small worked example: counting with a dictionary

A very common data task is counting how often each value appears. Here is how it looks built from the dictionary operations you now know, without using any loop constructs beyond the basic for loop shown above.

🐍Python
responses = ["yes", "no", "yes", "yes", "maybe", "no", "yes"]

counts = {}
for answer in responses:
    if answer in counts:
        counts[answer] = counts[answer] + 1
    else:
        counts[answer] = 1

print(counts)  # {'yes': 4, 'no': 2, 'maybe': 1}</>

Each time we see an answer, we check whether it is already a key in counts. If it is, we increase its count by one; if not, we start it at one. This pattern, building up a dictionary of counts or groups from a list, is extremely common in data work and will reappear, in a more compact form, once you learn comprehensions in Part 6.

Choosing the right collection

With all four types introduced, here is the decision in practical terms. Ask yourself three questions: does order matter, do I need to change it after creating it, and do I need unique items or labelled fields?

  • Need an ordered sequence you will modify, possibly with duplicates? Use a list, such as a column of readings you plan to sort or filter.
  • Need an ordered, fixed-size record that should never be edited after creation? Use a tuple, such as a coordinate pair or a row you are passing around unchanged.
  • Need to track unique items, remove duplicates, or test membership quickly? Use a set, such as the distinct customer IDs who placed an order.
  • Need to look values up by a meaningful label rather than a position? Use a dictionary, such as a record with named fields like name, price and in_stock.

A useful rule of thumb: if you find yourself writing parallel lists, such as names = [...] and ages = [...] where names[i] and ages[i] describe the same person, that is usually a sign you want a list of dictionaries instead, one dictionary per person, which keeps each person's fields together and is far less error-prone if the lists ever get out of sync.

Nesting collections: how real data actually looks

Collections become genuinely useful for data work once you nest them inside each other. The two patterns you will meet constantly, especially once you start reading CSV and JSON files in Part 7, are a list of dictionaries (rows of labelled data) and a dictionary of lists (columns of data under a label).

🐍Python
# a list of dictionaries: one dictionary per "row"
employees = [
    {"name": "Alice", "department": "Engineering", "salary": 72000},
    {"name": "Bob", "department": "Sales", "salary": 58000},
    {"name": "Carla", "department": "Engineering", "salary": 81000},
]

# access the salary of the second employee
print(employees[1]["salary"])   # 58000

# print every name
for employee in employees:
    print(employee["name"])</>

This shape, a list where each item is a dictionary, is exactly what you get back from many web APIs (Part 14 uses this directly) and is a natural way to represent table-like data before you load it into a more specialised tool. employees[1] gets the second dictionary (index 1), and ["salary"] then reads one field from it.

🐍Python
# a dictionary of lists: one list per "column"
sales_by_month = {
    "January": [1200, 1350, 980],
    "February": [1420, 1600, 1100],
    "March": [1300, 1250, 1400],
}

print(sales_by_month["February"])        # [1420, 1600, 1100]
print(sales_by_month["February"][0])     # 1420, the first value in February
print(sum(sales_by_month["March"]))      # 3950, total for March</>

Here, each key maps to a list, and you chain square brackets to reach inside: first the key to pick the month, then an index to pick a specific value within that month. This dictionary-of-lists shape is close to how a spreadsheet-like table is sometimes represented in plain Python, and it is the conceptual ancestor of what a DataFrame does once you reach pandas later in your learning path, though a DataFrame adds a great deal of convenience on top.

You can nest as deeply as you need, but readability suffers quickly past two or three levels. If you find yourself writing data["region"]["country"]["city"]["population"], it is often a sign that a small class (Part 9) or a different data structure would describe your data more clearly.

Common mistakes to watch for

A few mistakes come up repeatedly when people first use these collections. Knowing them in advance will save you time.

  • Confusing aliasing with copying: writing b = a for a list does not create a second list, it creates a second name for the same list. Use a.copy() or list(a) for an independent copy.
  • Forgetting the comma in a one-item tuple: (5) is an integer, (5,) is a tuple.
  • Using {} expecting an empty set: {} creates an empty dictionary. Use set() for an empty set.
  • Assuming a set or dictionary has a reliable position you can index into: sets have no order guarantee at all, and while dictionaries preserve insertion order since Python 3.7, you still access values by key, not by position, so my_dict[0] will raise a KeyError unless 0 is literally one of the keys.
  • Expecting .sort() to return a sorted list: it sorts in place and returns None. Use sorted(my_list) if you want a new sorted list back.
  • Trying to use a list as a dictionary key or a set member: lists are not hashable because they can change. Use a tuple instead if you need a fixed, list-like key.
  • Accessing a dictionary key that might not exist with square brackets: this raises a KeyError and stops the program. Use .get() with a sensible default when the key might be missing.

Summary and what's next

You now have four tools for grouping values, each suited to a different job. Lists hold ordered, changeable sequences and are your default choice for most collections. Tuples hold ordered, fixed records that should not change, and support convenient unpacking. Sets hold unique items with fast membership testing and support union, intersection and difference. Dictionaries map labels to values, letting you look things up by a meaningful key rather than a numeric position, and they preserve insertion order from Python 3.7 onwards. In practice, data work constantly combines these: a list of dictionaries to represent rows, a dictionary of lists to represent columns, a set to find unique values, a tuple to pass around a fixed pair of coordinates or a database record.

In Part 4, you will put these collections to work with conditions and loops, writing code that makes decisions and repeats actions over the lists, sets and dictionaries you have just learned to build. The counting example earlier in this article, built with a plain for loop and an if statement, is exactly the kind of pattern Part 4 will formalise and extend.