Python Lists & Dicts: The Complete Guide
Everything about Python's two workhorse data structures — creating, indexing, slicing, every method, comprehensions, nesting, copying, performance, and the gotchas — with a runnable example for every concept.
Lists and dicts are the two data structures you’ll use in almost every Python
program. Master these and you’ve mastered most of everyday Python. This guide
covers every basic concept for both — with a short, runnable example for
each — from creation to slicing to comprehensions to the gotchas that trip
everyone up. Outputs are shown as # => ... comments.
Lists
A list is an ordered, mutable sequence that can hold any mix of types and grows or shrinks on demand.
Creating lists
empty = [] # empty list
nums = [1, 2, 3] # literal
mixed = [1, "two", 3.0, True, None] # any types, any mix
from_iter = list("abc") # => ['a', 'b', 'c']
from_range = list(range(5)) # => [0, 1, 2, 3, 4]
repeated = [0] * 4 # => [0, 0, 0, 0]
nested = [[1, 2], [3, 4]] # lists inside lists
Indexing
letters = ['p', 'y', 't', 'h', 'o']
letters[0] # => 'p' (first)
letters[3] # => 'h'
letters[-1] # => 'o' (last — no need for len()-1)
letters[-2] # => 'h'
letters[99] # IndexError: list index out of range
Slicing — list[start:stop:step]
Slicing returns a new list. stop is exclusive. Any part is optional.
nums = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
nums[2:5] # => [2, 3, 4] (index 2 up to, not incl., 5)
nums[:3] # => [0, 1, 2] (from start)
nums[7:] # => [7, 8, 9] (to end)
nums[:] # => [0..9] (a full shallow copy)
nums[::2] # => [0, 2, 4, 6, 8] (every 2nd — step)
nums[::-1] # => [9, 8, ..., 0] (reversed!)
nums[-3:] # => [7, 8, 9] (last three)
nums[1:8:3] # => [1, 4, 7]
Out-of-range slices don’t error — they just clamp: nums[5:100] # => [5..9].
You can also assign to a slice to replace, insert, or delete a run:
xs = [1, 2, 3, 4, 5]
xs[1:3] = ['a', 'b', 'c'] # xs => [1, 'a', 'b', 'c', 4, 5] (replace 2 with 3)
xs[1:4] = [] # xs => [1, 4, 5] (delete a slice)
Mutating — lists change in place
xs = [10, 20, 30]
xs[1] = 99 # xs => [10, 99, 30]
xs[0:2] = [1, 2] # xs => [1, 2, 30]
Adding items
xs = [1, 2, 3]
xs.append(4) # xs => [1, 2, 3, 4] (add one to the end)
xs.append([5, 6]) # xs => [1, 2, 3, 4, [5, 6]] (appends the LIST as one item)
xs = [1, 2, 3]
xs.extend([4, 5]) # xs => [1, 2, 3, 4, 5] (add each item)
xs.insert(0, 99) # xs => [99, 1, 2, 3, 4, 5] (insert at index)
a = [1, 2] + [3, 4] # => [1, 2, 3, 4] (concatenate → new list)
b = [0] * 3 # => [0, 0, 0] (repeat)
append(x) adds x as a single element; extend(iterable) adds each item.
That difference is the #1 beginner mix-up.
Removing items
xs = ['a', 'b', 'c', 'b', 'd']
xs.remove('b') # removes the FIRST 'b' → ['a', 'c', 'b', 'd']
last = xs.pop() # removes & returns last → 'd', xs = ['a', 'c', 'b']
first = xs.pop(0)# removes & returns index 0 → 'a', xs = ['c', 'b']
del xs[0] # delete by index → ['b']
xs = [1, 2, 3]
xs.clear() # xs => [] (remove everything)
# del can also remove a slice, or the whole variable:
xs = [1, 2, 3, 4, 5]
del xs[1:3] # xs => [1, 4, 5] (delete a slice)
del xs # the variable itself is gone now
print(xs) # NameError: name 'xs' is not defined
remove(value)— by value (first match;ValueErrorif absent).pop(i)— by index, and returns it (pop()= last; great for stacks).del— by index or slice; returns nothing.
Searching & membership
xs = [10, 20, 30, 20]
20 in xs # => True (membership test)
40 not in xs # => True
xs.index(20) # => 1 (first position; ValueError if missing)
xs.index(20, 2) # => 3 (search starting at index 2)
xs.count(20) # => 2 (how many times)
# Full syntax: list.index(value, start, end) - search within a range only
fruits = ['apple', 'banana', 'cherry', 'kiwi', 'mango', 'orange', 'cherry']
fruits.index("cherry") # => 2 first match anywhere
fruits.index("cherry", 3, 7) # => 6 only look at indices 3..6
fruits.index("cherry", 4) # => 6 from index 4 onward
Sorting & reversing
xs = [3, 1, 4, 1, 5, 9, 2]
xs.sort() # sorts IN PLACE → [1, 1, 2, 3, 4, 5, 9]
xs.sort(reverse=True) # → [9, 5, 4, 3, 2, 1, 1]
words = ['banana', 'kiwi', 'fig']
words.sort(key=len) # by length → ['fig', 'kiwi', 'banana']
words.sort(key=str.lower) # case-insensitive
# sorted() returns a NEW list and leaves the original alone:
original = [3, 1, 2]
new = sorted(original) # new => [1, 2, 3], original unchanged
sorted(original, reverse=True, key=lambda x: -x)
xs.reverse() # reverse in place
list(reversed(xs)) # reversed as a new list
Key rule: .sort()/.reverse() mutate and return None; sorted()/reversed()
leave the original untouched and give you a new result. x = xs.sort() is a
classic bug — x becomes None.
Iterating
xs = ['a', 'b', 'c']
for item in xs:
print(item)
for i, item in enumerate(xs): # index + value
print(i, item) # 0 a / 1 b / 2 c
for i, item in enumerate(xs, start=1):
print(i, item) # 1 a / 2 b / 3 c
names, ages = ['Al', 'Bo'], [30, 25]
for name, age in zip(names, ages): # walk two lists together
print(name, age) # Al 30 / Bo 25
Useful built-ins
xs = [3, 1, 4, 1, 5]
len(xs) # => 5
sum(xs) # => 14
min(xs) # => 1
max(xs) # => 5
sorted(xs)# => [1, 1, 3, 4, 5]
any([0, '', 3]) # => True (at least one truthy)
all([1, 2, 3]) # => True (all truthy)
Unpacking
a, b, c = [1, 2, 3] # a=1, b=2, c=3
first, *rest = [1, 2, 3, 4] # first=1, rest=[2, 3, 4]
*init, last = [1, 2, 3, 4] # init=[1, 2, 3], last=4
a, *mid, b = [1, 2, 3, 4, 5] # a=1, mid=[2, 3, 4], b=5
List comprehensions
The Pythonic way to build a list from another iterable — often replacing a
for-loop-with-append.
squares = [x**2 for x in range(5)] # => [0, 1, 4, 9, 16]
evens = [x for x in range(10) if x % 2 == 0] # with a filter → [0,2,4,6,8]
labels = ['even' if x % 2 == 0 else 'odd' # if/else goes BEFORE the for
for x in range(4)] # => ['even','odd','even','odd']
pairs = [(x, y) for x in [1, 2] for y in ['a', 'b']]
# => [(1,'a'), (1,'b'), (2,'a'), (2,'b')] (nested loops)
flat = [n for row in [[1, 2], [3, 4]] for n in row] # => [1, 2, 3, 4]
Nested lists (2-D)
grid = [[1, 2, 3],
[4, 5, 6]]
grid[1][2] # => 6 (row 1, col 2)
[row[0] for row in grid] # first column → [1, 4]
# Build a 3×3 grid of zeros the RIGHT way:
grid = [[0] * 3 for _ in range(3)] # 3 independent rows
# WRONG: [[0] * 3] * 3 → three references to the SAME row! (see gotchas)
Copying — the aliasing trap
Assignment does not copy; it makes another name for the same list.
a = [1, 2, 3]
b = a # b is the SAME list, not a copy
b.append(4)
a # => [1, 2, 3, 4] ← a changed too!
# To actually copy:
c = a.copy() # or a[:] or list(a) (shallow copy)
c.append(99)
a # unchanged
# Shallow copies share NESTED objects:
import copy
nested = [[1, 2], [3, 4]]
shallow = nested.copy()
shallow[0].append(99) # nested[0] also becomes [1, 2, 99]!
deep = copy.deepcopy(nested) # fully independent
List methods at a glance
| Method | Does | Returns |
|---|---|---|
append(x) | add one item to the end | None |
extend(it) | add each item of an iterable | None |
insert(i, x) | insert x before index i | None |
remove(x) | delete first x by value | None |
pop([i]) | remove & return item (last by default) | item |
clear() | remove all items | None |
index(x) | first position of x | int |
count(x) | number of xs | int |
sort() | sort in place | None |
reverse() | reverse in place | None |
copy() | shallow copy | new list |
Dicts
A dict maps unique keys to values. It’s mutable, keys must be hashable (immutable-ish), and since Python 3.7 it keeps insertion order. Lookup by key is O(1) on average — its superpower.
Creating dicts
empty = {} # empty dict (NOT a set!)
person = {"name": "Ada", "age": 36} # literal
d = dict(name="Ada", age=36) # keyword form (string keys only)
pairs = dict([("a", 1), ("b", 2)]) # from (key, value) pairs
zipped = dict(zip(["a", "b"], [1, 2])) # => {'a': 1, 'b': 2}
defaults = dict.fromkeys(["x", "y"], 0) # => {'x': 0, 'y': 0}
Accessing values
person = {"name": "Ada", "age": 36}
person["name"] # => 'Ada'
person["email"] # KeyError: 'email' ← missing key raises!
# .get() is the safe way — returns None (or a default) instead of erroring:
person.get("email") # => None
person.get("email", "n/a") # => 'n/a' (custom default)
Use ["key"] when the key must exist (fail loud); use .get() when it
might not.
Adding & updating
d = {"a": 1}
d["b"] = 2 # add a new key → {'a': 1, 'b': 2}
d["a"] = 99 # update existing → {'a': 99, 'b': 2}
d.update({"c": 3, "a": 0}) # merge/overwrite from another dict/pairs
# → {'a': 0, 'b': 2, 'c': 3}
# setdefault: get key's value, inserting a default only if it's missing.
d = {}
d.setdefault("hits", 0) # returns 0 and sets d['hits'] = 0
d["hits"] += 1 # d => {'hits': 1}
Removing
d = {"a": 1, "b": 2, "c": 3}
del d["a"] # remove by key (KeyError if missing) → {'b':2,'c':3}
val = d.pop("b") # remove & RETURN → 2, d = {'c': 3}
val = d.pop("z", None) # with default → no error, returns None
k, v = d.popitem() # remove & return the LAST inserted pair → ('c', 3)
d.clear() # empty it → {}
# Copying a dict - two equivalent ways:
car = {"brand": "Ford", "model": "Mustang", "year": 1964}
mydict = car.copy() # method form
mydict = dict(car) # constructor form - same result
# del removes the whole dict variable:
del car
print(car) # NameError: name 'car' is not defined
Checking keys, values, items
d = {"a": 1, "b": 2}
"a" in d # => True (in checks KEYS)
1 in d # => False (not a key — it's a value)
1 in d.values() # => True (check values explicitly)
d.keys() # dict_keys(['a', 'b'])
d.values() # dict_values([1, 2])
d.items() # dict_items([('a', 1), ('b', 2)])
These are live views — they update if the dict changes. Wrap in list()
if you need a snapshot: list(d.keys()).
Iterating
d = {"a": 1, "b": 2, "c": 3}
for key in d: # iterating a dict yields its KEYS
print(key) # a b c
for key, value in d.items(): # the usual way — key AND value
print(key, value) # a 1 / b 2 / c 3
for value in d.values():
print(value) # 1 2 3
Dict comprehensions
squares = {x: x**2 for x in range(5)} # => {0:0, 1:1, 2:4, 3:9, 4:16}
prices = {"pen": 5, "book": 40, "eraser": 2}
cheap = {k: v for k, v in prices.items() if v < 10} # filter → {'pen':5,'eraser':2}
inverted = {v: k for k, v in prices.items()} # swap keys & values
upper = {k.upper(): v for k, v in prices.items()}
Merging dicts
a = {"x": 1, "y": 2}
b = {"y": 9, "z": 3}
{**a, **b} # => {'x':1, 'y':9, 'z':3} (unpack; b wins on 'y')
a | b # => {'x':1, 'y':9, 'z':3} (Python 3.9+ merge operator)
a.update(b) # mutates a in place → a = {'x':1, 'y':9, 'z':3}
Nested dicts
users = {
"u1": {"name": "Ada", "roles": ["admin"]},
"u2": {"name": "Bo", "roles": ["editor", "viewer"]},
}
users["u1"]["name"] # => 'Ada'
users["u2"]["roles"].append("x") # mutate a nested list
users.get("u3", {}).get("name") # => None (safe deep access)
Three everyday dict patterns
Counting — tally occurrences:
text = "banana"
counts = {}
for ch in text:
counts[ch] = counts.get(ch, 0) + 1 # get-with-default idiom
# counts => {'b': 1, 'a': 3, 'n': 2}
from collections import Counter # …or just use Counter
Counter("banana") # => Counter({'a':3, 'n':2, 'b':1})
Grouping — bucket items by a key:
words = ["apple", "avocado", "banana", "cherry"]
groups = {}
for w in words:
groups.setdefault(w[0], []).append(w)
# => {'a': ['apple','avocado'], 'b': ['banana'], 'c': ['cherry']}
from collections import defaultdict # …or defaultdict(list)
g = defaultdict(list)
for w in words:
g[w[0]].append(w) # no setdefault needed
Lookup table — replace long if/elif chains:
dispatch = {"add": lambda a, b: a + b, "mul": lambda a, b: a * b}
dispatch["add"](2, 3) # => 5
Keys must be hashable
Only immutable-ish (hashable) objects can be keys: strings, numbers, booleans, and tuples (of hashables). Lists and dicts cannot be keys.
d = {("lat", "lon"): "point", 42: "answer", "k": 1} # ✓ tuple/int/str keys
bad = {[1, 2]: "x"} # TypeError: unhashable type: 'list'
Dict methods at a glance
| Method | Does |
|---|---|
d[k] | get value (KeyError if missing) |
get(k, default) | get value, default/None if missing |
setdefault(k, d) | get k, inserting default if absent |
update(other) | merge in another dict/pairs |
pop(k[, default]) | remove & return value |
popitem() | remove & return last (key, value) |
keys() / values() / items() | live views |
clear() | remove everything |
copy() | shallow copy |
Lists vs dicts — which to use?
| List | Dict | |
|---|---|---|
| Access by | position (index) | key (name/id) |
| Order | ordered | insertion-ordered (3.7+) |
| Lookup speed | x in list is O(n) | k in dict is O(1) |
| Duplicates | allowed | keys unique (values can repeat) |
| Best for | a sequence/collection of things | labelled data, fast lookups, counting |
Rule of thumb: reaching for for x in big_list: if x == target a lot? A dict
(or set) will be far faster. Need order and position? A list.
The gotchas everyone hits
- Mutable default arguments.
def f(x, acc=[])reuses the same list on every call. Usedef f(x, acc=None): acc = acc or []instead. [[0]*3]*3makes three references to one row — editinggrid[0][0]edits all rows. Use[[0]*3 for _ in range(3)].x = xs.sort()setsx = None(sort mutates in place). Usesorted(xs).- Aliasing.
b = ais not a copy;b = a.copy()(ora[:]) is. - Modifying while iterating a list or dict raises or misbehaves — iterate a
copy (
for x in xs[:]) or build a new one. {}is an empty dict, not a set. For an empty set, useset().d["missing"]raisesKeyError— use.get()when unsure.
Takeaways
- Lists = ordered, mutable sequences; index & slice by position, tons of methods, and comprehensions for building them cleanly.
- Dicts = key → value maps with O(1) lookup, insertion order, and the get/setdefault/Counter/defaultdict idioms for counting and grouping.
- Watch mutation vs copy (
.sort()returnsNone,b = aaliases) — most Python bugs for beginners live right there.