Data structures — study guide

The concept's fragments, read in order.

Pick the shape that fits the problem

Python hands you four built-in ways to hold a group of values: a list, a dict, a set, and a tuple. They can all store the same data, so the choice between them is never about what fits — it is about which one makes the thing you do over and over cheap. Reach for the wrong shape and every operation becomes a fight; reach for the right one and the problem half-solves itself.

Each shape is good at something and awkward at the rest like the containers in a kitchen: a jar, a labeled spice rack, a bag for the shopping, and a small sealed box each suit a job the others do badly, so which one you grab depends on what you are about to do. A list keeps things in order and lets you change them. A dict finds a value by a name you chose for it. A set remembers only which things are present, never duplicates or order. A tuple is a small fixed record you group together and leave alone. The differences are exactly ordering, whether the contents can change, whether duplicates are kept, and how you get a value back out.

So the real skill of this concept is a habit of asking one question before you store anything: what am I going to do with this the most — walk it in order, look one thing up by name, test whether something is present, or bundle a few fields that belong together? The answer names the shape. What follows is each one in turn, then the moves they share, then a short guide back to that question.

"Lists: an ordered, changeable sequence"

A list holds values in the order you put them and lets you change that order and those values later. You write one with square brackets, readings = [12.5, 13.1, 12.9], and you reach any element by its position: readings[0] is the first, readings[-1] is the last. Counting from zero and from the end both come up constantly, so they are worth having in your fingers. A list keeps duplicates without complaint — the same value can appear as many times as it occurs.

Because a list is changeable, it grows and shrinks in place. readings.append(14.0) adds one value to the end; readings.extend(more) adds several; assigning readings[1] = 13.5 replaces one. This is the shape you want whenever the collection is a running record — items arriving over time, steps in a sequence, a log you keep adding to. Walking it in order is natural: a for loop hands you each element, front to back.

The one thing a list is not built for is finding a value by content. Asking whether a value is present, or where it sits, means Python checks elements one after another until it finds a match or runs off the end. For a short list that is nothing; for a long one that you keep searching, it is a signal that the data wants a different shape — one indexed by the thing you actually search on.

"Dicts: look up by key, not by scanning"

dict "milk" direct 4 key to value list "milk"? "apple" "bread" "milk" check each entry in turn
A dict goes straight from a key to its value, while a list must check entries one by one to find the same value.

A dict stores values under keys you choose, and its whole point is that finding a value by its key is direct. You write one as a set of key: value pairs in curly braces, and you get a value straight back with prices["apple"] — no walking the collection to find it like a phone book indexed by name: you turn straight to the entry you want instead of reading every line from the front to find it. Testing "apple" in prices is the same direct move: it asks about a key, not a value, and answers without a scan.

This is the answer to the problem a list leaves open. A list of name-and-value pairs forces you to walk every pair to find the one you want; a dict is that same data turned so the name is the way in. The instant you catch yourself searching a list for "the one whose name is X", the list wants to become a dict keyed by that name. Keys are unique, so each name points to exactly one value — assigning to an existing key replaces what was there rather than adding a second copy.

Two everyday moves round it out. prices.get("eggs", 0) returns a fallback when the key is missing instead of raising an error, which keeps lookups on untrusted keys calm. And iterating a dict gives you its keys, while prices.items() gives you each key with its value together — so the same structure that answers a single lookup instantly also walks cleanly when you need the whole thing. Insertion order is kept, so that walk is in the order you built it.

"Sets: uniqueness and fast membership"

A set remembers only which values are present — never in what order, never more than once. Its two everyday jobs follow straight from that. First, uniqueness: building a set from a list, set(readings), drops every duplicate in one move, leaving one of each. Second, membership: value in seen answers whether something is present directly, the way a dict answers about a key, rather than scanning the way a list does. When the question you keep asking is "have I seen this already?", a set is the shape that makes it cheap.

Because a set is about presence rather than position, it has no index — there is no seen[0], and the elements have no order you can rely on. What it offers instead is set arithmetic. Two sets combine with a & b for the values in both, a | b for the values in either, and a - b for the values in the first but not the second. These turn questions about overlapping groups — shared items, missing items, combined items — into a single readable expression.

The catch is the same one that governs dict keys: a set can only hold values that do not change identity, so numbers, strings, and tuples are fine, but you cannot put a list into a set. That is rarely a real limit, because the things you dedup or test for membership — ids, names, coordinates — are exactly the fixed values a set accepts.

"Tuples: a fixed record"

A tuple groups a few values that belong together and then leaves them alone. You write one with commas, usually inside parentheses, point = (3, 4), and like a list it keeps order and lets you read by position. The difference is that a tuple cannot be changed after it is made: there is no append, and assigning to point[0] is an error. That sounds like a restriction, and it is exactly the point — a tuple is for a record whose shape is fixed, where the first slot always means one thing and the second always means another.

The natural way to take a tuple apart is to unpack it: x, y = point binds two names in one line, each to its slot. This reads far better than pulling values out by index, and it is why functions that hand back several results return a tuple — the caller unpacks it into named pieces. A tuple is the right shape whenever values travel together as a unit: a coordinate, a row, a labeled pair.

Immutability also buys a tuple something a list cannot have: because it never changes, a tuple can be hashed, so it can serve as a dict key or a set member. A pair of coordinates can key a dictionary of what sits at each location; a list of the same two numbers cannot, because a key that could change out from under the dict would break it. This is the quiet reason to reach for a tuple even when a list would hold the same values.

Walking through, and asking is it in there

Two operations show up on every collection, and knowing how each one behaves per shape is most of using them well. The first is iteration: a for loop walks the contents. Over a list or a tuple it hands you each element in order. Over a set it hands you each unique element, but in no order you should count on. Over a dict it hands you the keys — and when you want the values with them, .items() yields each key and value together, which is the loop you reach for most.

The second is membership: the in test, asking whether something is present. Here the shape matters not just for what you ask but for what it costs. On a list or tuple, in walks the elements one by one until it finds a match, so a search over a long sequence does real work every time. On a dict or set, in is a direct lookup that does not depend on how many items are stored — it goes more or less straight to the answer. That gap is the whole practical reason those shapes exist.

So the two operations pull in different directions, and that tension is the heart of choosing a shape. Ordered walking is a sequence's strength; instant "is it there?" is a dict or set's. When you need both — walk in order and also test membership fast — it is common to keep the sequence for the walk and a set alongside it for the test, each doing the job it is good at.

What can change, and why it matters

a b b = a [12.5, 13.1] one object append via b, a sees it too
Assigning b = a binds one list object to two names, so a change made through either name is seen through the other.

The four shapes split cleanly on one question: can the contents change after the collection is made? A list, a dict, and a set are mutable — you add, remove, and replace in place. A tuple and a string are immutable — once made, they are fixed, and every operation that looks like a change actually builds a new value. This is not a detail to memorize but the property that decides where each shape is safe to use.

The catch with mutability is aliasing. Assigning b = a does not copy a list; it gives the same list a second name. Append through b and the change is there through a, because there was only ever one list. This is the source of a whole class of surprising bugs, where a value you thought was untouched changed because something else held the same object. When you want an independent copy, you ask for one — list(a) or a.copy() makes a separate list that the original's changes do not reach.

Immutability is the flip side, and it is what makes a tuple or a string usable as a dict key or a set member. A key has to stay put: if it could change after it went in, the collection could no longer find it. Because an immutable value cannot change, it is safe in that role, while a mutable list is refused outright. So the mutable-versus-immutable line is not just about avoiding aliasing surprises — it is the rule that governs which values can name things and which can only be named.

Build a collection in one line

A very common loop does nothing but build a new collection from an old one: start an empty list, walk the source, maybe skip some items, transform the rest, append each result. A comprehension is that whole loop written as one expression. [n * n for n in nums if n % 2 == 0] reads left to right as what it produces: the square of each n, drawn from nums, kept only when n is even. The for names the source, the optional if filters, and the expression at the front transforms — the same three parts as the loop, with the noise removed.

The shape of the brackets picks the shape of the result. Square brackets build a list. Curly braces with a key: value expression build a dict, so you can turn a sequence into a lookup table in one line. Curly braces with a single expression build a set, filtering and deduping at once. The same reading works for all three: front is the item, middle is where it comes from, end is which ones to keep.

The value is readability, not cleverness, so the rule is to keep them simple. A comprehension that filters and transforms in one clear step is easier to read than the loop it replaces. One that stacks several conditions and nested loops is harder, and at that point the plain loop is the honest choice. Reach for a comprehension when the result is a straightforward collection built from another; fall back to a loop when the logic wants room to breathe.

Choosing the shape

The four shapes answer four different questions, and naming the question names the shape. Do you need the values in order, and to keep changing them? That is a list. Do you look things up by a name you assign them? That is a dict. Do you only care whether something is present, with no duplicates? That is a set. Do a few values belong together as a fixed record? That is a tuple. Most of the time one question dominates, and the shape falls out of it before you write a line.

The clearest signal that you have chosen wrong is a scan where a lookup belongs. Searching a list over and over for "the one whose name is X" is the classic case, and the fix is almost always to key a dict by that name. Repeatedly asking whether a value is among many is the same story with a set. Letting a group of fields drift as a mutable list when they are really a fixed record is the quieter mistake a tuple prevents. The shapes are cheap to switch between early and expensive to switch late, so it pays to ask the question up front.

None of this is exotic — it is the everyday judgment that makes ordinary code clean, and it keeps paying off as programs grow. The larger tools you meet later, from the objects that bundle data with behavior to the libraries that wrangle tables of it, are built out of exactly these four shapes. Get fluent in choosing among them and the rest of the build stands on something solid.