learn.aathan.in

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.

'p''y''t''h''o' 01234 -5-4-3-2-1
Every element has two indices: a positive one from the front (0-based) and a negative one from the back (starting at −1).

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; ValueError if 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

MethodDoesReturns
append(x)add one item to the endNone
extend(it)add each item of an iterableNone
insert(i, x)insert x before index iNone
remove(x)delete first x by valueNone
pop([i])remove & return item (last by default)item
clear()remove all itemsNone
index(x)first position of xint
count(x)number of xsint
sort()sort in placeNone
reverse()reverse in placeNone
copy()shallow copynew 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.

'name''age''admin' 'Ada'36True
Each unique key points to a value. You look things up by key, not by position.

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

MethodDoes
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?

ListDict
Access byposition (index)key (name/id)
Orderorderedinsertion-ordered (3.7+)
Lookup speedx in list is O(n)k in dict is O(1)
Duplicatesallowedkeys unique (values can repeat)
Best fora sequence/collection of thingslabelled 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. Use def f(x, acc=None): acc = acc or [] instead.
  • [[0]*3]*3 makes three references to one row — editing grid[0][0] edits all rows. Use [[0]*3 for _ in range(3)].
  • x = xs.sort() sets x = None (sort mutates in place). Use sorted(xs).
  • Aliasing. b = a is not a copy; b = a.copy() (or a[:]) 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, use set().
  • d["missing"] raises KeyError — 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() returns None, b = a aliases) — most Python bugs for beginners live right there.