Data Structures
Python ships four built-in containers that cover almost every need: list, tuple, dict, and set. Knowing which to pick, and how each behaves when copied or mutated, is most of what “knowing Python data structures” means.
A mutable, ordered sequence backed by a dynamic array of pointers. It can hold mixed types, though homogeneous lists are the norm.
nums = [3, 1, 4, 1, 5]nums[0] # => 3nums[-1] # => 5len(nums) # => 5Methods
Section titled “Methods”xs = [1, 2, 3]
xs.append(4) # [1, 2, 3, 4] — add one item, O(1) amortisedxs.extend([5, 6]) # [1, 2, 3, 4, 5, 6] — add every item of an iterablexs.insert(0, 0) # [0, 1, ...] — O(n), shifts everything rightxs.pop() # removes and returns the LAST item, O(1)xs.pop(0) # removes and returns index 0, O(n)xs.remove(3) # removes the FIRST item equal to 3; ValueError if absentxs.index(4) # first index of 4; ValueError if absentxs.count(1) # how many times 1 appearsxs.reverse() # in place, returns Nonexs.sort() # in place, returns Nonexs.clear() # empty itxs.copy() # shallow copy — same as xs[:]Slicing, including assignment
Section titled “Slicing, including assignment”Slices read as covered in types and variables, but on lists they are also assignable and deletable.
xs = [0, 1, 2, 3, 4, 5]xs[1:3] # => [1, 2] a NEW list (shallow copy of that region)xs[1:3] = ["a"] # => [0, 'a', 3, 4, 5] — replacement may change lengthdel xs[::2] # delete every other elementxs[:] = [9, 9] # replace CONTENTS in place — all aliases see the changeThe last line matters: xs = [9, 9] rebinds the name, xs[:] = [9, 9] mutates the object other references share.
Sorting
Section titled “Sorting”list.sort() sorts in place; sorted(iterable) returns a new list and works on anything iterable. Both accept key and reverse, and both are stable — equal elements keep their relative order.
words = ["banana", "Kiwi", "apple"]
sorted(words) # => ['Kiwi', 'apple', 'banana'] — capitals sort firstsorted(words, key=str.lower) # => ['apple', 'banana', 'Kiwi']sorted(words, key=len) # => ['Kiwi', 'apple', 'banana']sorted(words, reverse=True)
people = [{"name": "Ada", "age": 36}, {"name": "Linus", "age": 28}]sorted(people, key=lambda p: p["age"])
from operator import itemgetter, attrgettersorted(people, key=itemgetter("age")) # faster and clearer than a lambdasorted(objs, key=attrgetter("created_at"))Multi-key sorting: return a tuple. Tuples compare element by element.
sorted(people, key=lambda p: (p["age"], p["name"])) # age asc, then name ascsorted(people, key=lambda p: (-p["age"], p["name"])) # age DESC, then name ascAn immutable sequence. Same indexing and slicing as a list, no mutating methods.
point = (3, 4)point[0] # => 3point[0] = 9 # TypeError
() # empty tuple(1,) # ONE-element tuple — the comma makes it, not the parens(1) # just the integer 1Parentheses are usually optional; the comma is what creates a tuple:
t = 1, 2, 3 # => (1, 2, 3)Why tuples exist
Section titled “Why tuples exist”- Immutability as a contract. A tuple signals “this is a fixed record”, not a growable collection.
- Hashability. A tuple of hashable items is hashable, so it can be a dict key or set member. A list cannot.
- Packing and unpacking. Multiple return values are just tuples.
counts = {}counts[(2024, "Q1")] = 10 # composite key
def min_max(xs): return min(xs), max(xs) # returns a tuple
lo, hi = min_max([3, 1, 4])namedtuple and NamedTuple
Section titled “namedtuple and NamedTuple”When a tuple’s positions have meaning, name them. collections.namedtuple builds a tuple subclass with attribute access.
from collections import namedtuple
Point = namedtuple("Point", ["x", "y"])p = Point(3, 4)p.x # => 3p[0] # => 3 — still a tuplex, y = p # still unpacksp._replace(x=10) # => Point(x=10, y=4) — a new instancep._asdict() # => {'x': 3, 'y': 4}The typed version reads better in modern code:
from typing import NamedTuple
class Point(NamedTuple): x: int y: int = 0 # defaults allowed, trailing onlyUse a NamedTuple when you want a lightweight immutable record that still behaves as a tuple; use a dataclass when you want a mutable object with methods.
A mapping from hashable keys to arbitrary values, implemented as a hash table. Lookup, insert, and delete are O(1) on average.
user = {"name": "Ada", "age": 36}user["name"] # => 'Ada'user["email"] # KeyErroruser["email"] = "a@b.c" # insert or overwritedel user["age"]"name" in user # => True — membership tests KEYSlen(user)Ordering
Section titled “Ordering”Since Python 3.7 dicts preserve insertion order as a language guarantee (CPython 3.6 had it as an implementation detail). Iteration, keys(), values(), and repr all follow insertion order. Re-assigning an existing key does not move it; deleting and re-inserting does.
collections.OrderedDict is now needed only for its extras: move_to_end(), popitem(last=False), and order-sensitive ==.
Methods
Section titled “Methods”d = {"a": 1, "b": 2}
d.get("z") # => None — no KeyErrord.get("z", 0) # => 0 — with a defaultd.setdefault("c", []) # returns d['c'], inserting [] first if missingd.pop("a") # remove and return; optional defaultd.popitem() # remove and return the LAST (key, value) paird.update({"c": 3}) # merge another mapping (or kwargs, or pairs)d.keys(), d.values(), d.items() # dynamic views, not listsdict.fromkeys(["a", "b"], 0) # => {'a': 0, 'b': 0}Merging, 3.9+:
merged = {"a": 1} | {"b": 2} # => {'a': 1, 'b': 2}d |= {"c": 3} # in-place merge{**a, **b} # works on all versionsViews are live windows onto the dict, and key views support set operations:
d = {"a": 1}ks = d.keys()d["b"] = 2list(ks) # => ['a', 'b'] — the view updatedd.keys() & {"a", "z"} # => {'a'} — set intersection on keysIteration
Section titled “Iteration”for key in d: ... # keys, by defaultfor key, value in d.items(): ... # the idiomatic formfor value in d.values(): ...Keys must be hashable
Section titled “Keys must be hashable”A key must implement __hash__ and __eq__, and its hash must not change over its lifetime. All immutable built-ins qualify; lists, dicts, and sets do not.
{[1, 2]: "x"} # TypeError: unhashable type: 'list'{(1, 2): "x"} # fineset and frozenset
Section titled “set and frozenset”An unordered collection of unique, hashable elements. Membership testing is O(1), against O(n) for a list.
s = {1, 2, 3}empty = set() # {} is an empty DICT, not a sets.add(4)s.discard(9) # no error if absents.remove(9) # KeyError if absents.pop() # removes an arbitrary element3 in s # => TrueSet algebra, with operator and method forms:
a, b = {1, 2, 3}, {3, 4}
a | b # a.union(b) => {1, 2, 3, 4}a & b # a.intersection(b) => {3}a - b # a.difference(b) => {1, 2}a ^ b # a.symmetric_difference(b) => {1, 2, 4}
a <= b # a.issubset(b)a >= b # a.issuperset(b)a.isdisjoint(b)The method forms accept any iterable; the operator forms require both sides to be sets.
{1, 2}.union([2, 3]) # => {1, 2, 3}{1, 2} | [2, 3] # TypeErrorA common use is deduplication, at the price of losing order:
list(set(items)) # unique, order lostlist(dict.fromkeys(items)) # unique, ORDER PRESERVEDfrozenset is the immutable, hashable version — usable as a dict key or inside another set.
regions = {frozenset({"eu", "uk"}): "europe"}Comprehensions
Section titled “Comprehensions”A comprehension builds a container from an iterable in one expression. It is faster than the equivalent append loop and, when short, easier to read.
# listsquares = [n * n for n in range(10)]evens = [n for n in range(20) if n % 2 == 0]pairs = [(x, y) for x in "ab" for y in (1, 2)] # nested loops, outer first
# dictlengths = {w: len(w) for w in words}inverted = {v: k for k, v in d.items()}
# setinitials = {name[0] for name in names}
# generator expression — lazy, no brackets needed as a sole argumenttotal = sum(n * n for n in range(1_000_000))The if filters; a conditional expression goes before the for when you want to transform rather than filter:
[n if n > 0 else 0 for n in nums] # transform every element[n for n in nums if n > 0] # keep only someThe generator version allocates nothing:
sum([n * n for n in range(10_000_000)]) # builds a 10M-element list firstsum(n * n for n in range(10_000_000)) # constant memoryPython 3.12 (PEP 709) inlines list, dict, and set comprehensions rather than creating a hidden function, making them noticeably faster. Behaviour is unchanged.
Choosing the right structure
Section titled “Choosing the right structure”| Need | Use | Why |
|---|---|---|
| Ordered, growable sequence | list |
Append/pop at end O(1) |
| Fixed record, or a dict key | tuple |
Immutable, hashable |
| Named fixed record | NamedTuple |
Self-documenting, still a tuple |
| Key → value lookup | dict |
O(1) average lookup |
| Uniqueness / fast membership | set |
O(1) in |
| Immutable set | frozenset |
Hashable |
| Queue / deque, appends at both ends | collections.deque |
O(1) appendleft/popleft |
| Counting occurrences | collections.Counter |
Built-in tallying |
| Default value per missing key | collections.defaultdict |
No setdefault boilerplate |
Complexity cheat sheet:
| Operation | list | dict | set |
|---|---|---|---|
x in c |
O(n) | O(1) avg | O(1) avg |
| index / key lookup | O(1) | O(1) avg | — |
| append / add | O(1) amortised | O(1) avg | O(1) avg |
| insert / delete at front | O(n) | — | — |
Shallow vs deep copy
Section titled “Shallow vs deep copy”Assignment does not copy. A shallow copy makes a new outer container holding the same inner objects. A deep copy recursively copies everything.
original = [[1, 2], [3, 4]]
alias = original # not a copy at allshallow = original.copy() # also list(original) or original[:]shallow[0].append(99)original # => [[1, 2, 99], [3, 4]] — inner list is shared!import copydeep = copy.deepcopy(original)deep[0].append(99)original # unchangedShallow-copy recipes: list(x), x[:], x.copy(), dict(d), set(s), copy.copy(obj).
Use deepcopy when the structure is nested and you must isolate it fully. Be aware it is slow, it follows __deepcopy__ if defined, and it handles cycles correctly (it memoises objects it has already copied). It cannot copy things like open sockets or file handles.
Key points
Section titled “Key points”- Lists mutate in place and their mutating methods return
None. sort()/sorted()are stable and takekey; tuple keys give multi-level sorts.- A tuple is defined by the comma, is hashable, and makes a good record or dict key.
- Dicts preserve insertion order since 3.7; use
get/setdefaultto avoidKeyError. - Sets give O(1) membership;
dict.fromkeysdedupes while keeping order. - Comprehensions build containers in one expression; generator expressions do it lazily.
- Copies are shallow unless you ask for
copy.deepcopy.