Skip to content

Lists

docs.scrimba.com

A variable holds one thing. A list holds many things in order, all under one name. A leaderboard is a ranked sequence of scores. A quiz is a collection of questions. Once you need to manage a group of related values, you need a list.

Lists are Python's general-purpose ordered, mutable sequence. They are the natural fit for anything that changes over time: items added or removed, order shuffled, contents filtered or sorted. When order matters and the collection changes, a list is usually the right first choice.

A list is Python's dynamic array: an ordered, mutable (changeable in place) sequence. The performance profile is what drives design decisions. Reading by index is O(1), constant time no matter how big the list (big-O is shorthand for how cost scales with size, n). append() is effectively O(1) too: the list keeps spare room at the end and only resizes occasionally. The costs that bite are insert() and remove() at O(n), because every element after the change point shifts over. When you find yourself inserting or removing at the front in a loop, that O(n) is the signal to reach for a different structure.

Creating a list

Square brackets, values separated by commas. Lists can hold any mix of types, and an empty list is valid and common as a starting point you build up over time.

Lists are defined with bracket syntax and preserve insertion order. They can hold any Python value, including other lists. The empty list [] is the standard starting point when you accumulate items incrementally.

A bracket literal creates a new list object, and the list stores references to its elements, not the values copied in (a reference is a pointer to where the real object lives). So an element can be any Python object, including another list. Mixing types in one list is valid but uncommon outside quick scripts: most lists you keep around hold one type, which makes them easier to reason about and to process in a loop.

python
scores = [87, 92, 74, 65, 91]
players = ["Alice", "Bob", "Charlie"]
mixed = ["Alice", 87, True, 3.14]   # any types, though uncommon
empty = []
JunoCreating a list Values between square brackets, separated by commas, and Python keeps them in the order you wrote them. An empty [] is a fine starting point when you plan to fill it up as you go. Most of my lists start out that way, empty and waiting.
JunoCreating a list Brackets define a list and insertion order is preserved. A list can hold any value, including other lists, and [] is the standard start when you accumulate items incrementally.
JunoCreating a list A list holds references to its elements, so it can mix any objects, though one type per list is what you actually want to keep around. Reach for [] and build up, the over-allocation makes that cheap.

Indexing and slicing

Lists use the same numbering as strings: positions start at 0, negative numbers count from the end. You read any item by its position. Because lists are mutable, you can also write to a specific position.

List indexing and slicing follow the same rules as strings. The key difference is mutability: you can assign to an index or a slice to change items in place, something strings do not allow.

Indexing and slicing read the same as on a string, but a list lets you assign too, since it is mutable. The one to know is slice assignment: it can change the list's length. lst[1:3] = [10, 20, 30] replaces the two items at positions 1 and 2 with three new ones, growing the list by one. The replacement does not have to match the slice's length, which is convenient and a common surprise, so reach for it deliberately rather than by accident.

python
scores = [87, 92, 74, 65, 91]

scores[0]      # 87  (first)
scores[-1]     # 91  (last)
scores[1:3]    # [92, 74]
scores[:2]     # [87, 92]
scores[::-1]   # [91, 65, 74, 92, 87]  (reversed)

scores[0] = 90   # mutable: works (strings would raise TypeError)
JunoIndexing and slicing Positions start at 0, and negative numbers count back from the end, so scores[-1] is the last one. A slice like scores[1:3] hands you a fresh list with positions 1 and 2. The part that took me a moment: with a list you can also write to a position, scores[0] = 90, where a string would refuse.
JunoIndexing and slicing Same indexing and slicing as strings, positions from 0, negatives from the end. The difference is mutability: assign to an index or a slice and you change the list in place, where a string would raise TypeError.
JunoIndexing and slicing Reads like string indexing, but you can assign, and slice assignment can resize the list. lst[1:3] = [10, 20, 30] swaps two items for three, handy on purpose and a surprise by accident, so reach for it deliberately.

Adding items

Three methods to add items. append() adds a single item to the end and is what you will use almost every time. insert() adds at a specific position. extend() merges another list in.

append() is O(1) amortised (the list keeps spare room and only resizes occasionally, so the average cost stays constant) and is the standard way to build a list item by item. insert() is O(n) because it shifts subsequent elements. extend() is equivalent to += and more efficient than repeated append() inside a loop.

append() is cheap because the list keeps spare room and only resizes once in a while, so the average cost stays O(1) (constant, regardless of length). insert(0, x) is the trap: inserting at the front is O(n) because every element shifts right one slot. If you build a list by repeatedly adding to the front, that turns into O(n squared) overall. When you need fast front insertion, use collections.deque from the standard library, whose appendleft is O(1), covered in the Modules chapter. extend() grows the list in one pass, which is why it beats a loop of append() calls when you already have the items.

python
scores = [87, 92, 74]

scores.append(65)          # [87, 92, 74, 65]
scores.insert(1, 100)      # [87, 100, 92, 74, 65]
scores.extend([55, 71])    # [87, 100, 92, 74, 65, 55, 71]

A common mistake: append() with a list adds the whole list as one item, giving you a list inside a list. Use extend() to merge instead:

append(x) always adds x as a single element. Passing a list to append() gives you a nested list. Use extend() when you want to merge all items from another list into this one:

append(x) adds x as one element no matter what x is, so handing it a list nests that list whole. extend(iterable) walks the argument and adds each element separately. lst += other does the same thing as extend, so reach for whichever reads clearer at the call site.

python
scores.append([55, 71])    # [..., [55, 71]]  nested list, probably wrong
scores.extend([55, 71])    # [..., 55, 71]    merged, correct
JunoAdding itemsappend() tacks one item onto the end and is what you will reach for nearly every time. insert() drops an item at a position, extend() merges another list in. The classic slip: append() a list and you get a list inside your list, so use extend() when you mean to merge.
JunoAdding itemsappend() is your default, item by item onto the end. insert() shifts everything after it, so it costs more. append() a list and it nests; extend() (or +=) merges the items in instead.
JunoAdding itemsappend() is amortised O(1), insert(0, x) is O(n), so building front to back in a loop quietly goes quadratic, reach for deque there. And append() a list nests it whole; extend() or += is the merge.

Removing items

Four tools to remove items. remove() searches by value. pop() removes by position and hands you the item back. del removes by position without a return value. clear() empties the whole list.

remove() is O(n): it scans for the first occurrence by value. pop() without an argument is O(1) for the last item. pop(i) for any other position is O(n) because elements shift. del scores[i] is equivalent to pop(i) but discards the return value.

remove(value) scans from the front comparing each element until it finds a match, then shifts everything after it left: O(n) (cost grows with the list's length). pop() off the end is O(1), nothing shifts. pop(i) anywhere else is O(n) for the same shifting reason. If you find yourself removing from arbitrary positions in a hot loop, that repeated O(n) is the cue to restructure the data or pick a different collection.

python
scores = [87, 92, 74, 65, 91]

scores.remove(74)    # removes first occurrence of 74
scores.pop()         # removes and returns last item (91)
scores.pop(0)        # removes and returns item at position 0 (87)
del scores[1]        # removes at position 1, no return value
scores.clear()       # removes everything

remove() raises a ValueError if the value is not in the list. Check with in first if you are not certain:

python
if 74 in scores:
    scores.remove(74)

remove() raises ValueError on a miss. The in check adds an extra O(n) scan, so you walk the list twice. For one-off code that is fine. When you would rather catch the failure than pre-check it, try / except ValueError is covered in the Files and exceptions chapter.

The in then remove() pattern is two O(n) scans, the list walked twice. When order does not matter, swapping the target with the last element and popping it is O(1) (constant cost, no shifting). And if you are testing membership a lot, a set looks up in O(1) where a list is O(n), covered in the Tuples and sets chapter. The rule of thumb: a list for ordered, changing data, a set the moment "is it in there" is the hot question.

JunoRemoving itemsremove() deletes by value and only the first match, and it raises ValueError if the value is not there, so check with in first when you are not sure. pop() removes by position and hands the item back, del removes by position and hands you nothing.
JunoRemoving itemsremove() deletes the first value match and raises ValueError on a miss. pop() off the end is cheap and returns the item; pop(i) elsewhere shifts everything after it. del removes by position, no return.
JunoRemoving itemsremove() and pop(i) away from the end are O(n) shifts; only pop() off the tail is O(1). If "is it in there" is the hot path, a set does it in O(1) where the list is O(n). Pre-checking with in before remove() walks the list twice.

Sorting

sorted() returns a brand new sorted list and leaves your original untouched. .sort() sorts the list in place and returns None. That difference matters more than it sounds.

sorted() is the safe default: it never modifies the original. .sort() modifies in place and returns None, which is a common trap. Assigning the result of .sort() gives you None, not the sorted list. Use sorted() when you need the original intact; use .sort() when you only want the sorted version.

Python's sort is stable: equal elements keep their original relative order. That is what lets you sort by one key, then sort again by another, and have the first ordering survive as a tiebreaker. It is also fast on nearly-sorted data, close to O(n) (linear) rather than the O(n log n) of a sort from scratch, so re-sorting a mostly-ordered list is cheap. .sort() returns None on purpose: a method that changes the list in place does not also return it, which is the convention that stops you from writing x = lst.sort() and quietly getting None. sorted() takes any iterable, not only lists, and always returns a fresh list.

python
scores = [87, 42, 96, 55, 71]

ranked = sorted(scores)            # [42, 55, 71, 87, 96] (new list)
scores.sort()                      # sorts in place, returns None
scores.sort(reverse=True)          # [96, 87, 71, 55, 42]

result = scores.sort()             # result is None, not the sorted list
JunoSortingsorted() gives you a new sorted list and leaves the original alone. .sort() rearranges the list itself and returns None, so x = scores.sort() lands you with None, not the list. I made that exact mistake more than once before it stuck.
JunoSortingsorted() is the safe default, it never touches the original. .sort() sorts in place and returns None, so assigning its result is the trap to remember. Pick sorted() when you need the original intact.
JunoSorting The sort is stable, so sort by a secondary key first, then the primary, and ties hold their earlier order. It runs near-linear on nearly-sorted data, so re-sorting is cheap. .sort() returns None by design; sorted() takes any iterable and returns a new list.

Useful operations

Python has a set of built-in tools that work directly on lists. len(), sum(), min(), and max() are the four you will reach for constantly.

The built-in sequence functions work on any list. in is a linear scan for lists; if you need fast repeated membership tests, convert to a set. .index() raises ValueError if the value is not found.

len(), sum(), min(), and max() work on any iterable, not only lists, so the same call reads a list, a tuple, or a generator. The cost note that matters: in and .index() both scan front to back, O(n) (cost grows with length), because a list has no index of its contents. Repeated membership checks in a loop are where that adds up, and a set is the fix. One sharp edge: sum() starts from 0, so it adds numbers, not strings; to join strings use "".join(), covered in the Strings chapter.

python
scores = [87, 92, 74, 65, 91]

len(scores)          # 5
sum(scores)          # 409
min(scores)          # 65
max(scores)          # 92
scores.count(87)     # 1
scores.index(74)     # 2
74 in scores         # True
74 not in scores     # False
scores.copy()        # shallow copy
scores.reverse()     # reverses in place
JunoUseful operationslen(), sum(), min(), and max() all work straight on a list, no setup. in asks whether something is present, .count() tallies how many times, and .index() finds the position of the first match.
JunoUseful operations The built-in functions work on any list, and the sequence ones work on any iterable. in is a linear scan, so for repeated membership tests convert to a set. .index() raises ValueError when the value is not found.
JunoUseful operationslen(), sum(), min(), max() take any iterable, not only lists. in and .index() are both O(n) scans, so a hot membership check wants a set. And sum() starts from 0, so for strings reach for "".join().

Iterating

A for loop goes through a list one item at a time. The variable after for receives each item in turn. When you also need the position, enumerate() gives you both without a manual counter.

for item in list invokes the list's iterator and advances it on each step. enumerate(iterable, start=0) wraps the iterator and yields (index, value) pairs. Using enumerate() is cleaner and less error-prone than maintaining a counter variable.

enumerate() yields (index, value) pairs as you loop, which is why for i, item in enumerate(...) reads cleanly: each pair unpacks into the two names. The start argument shifts the counter for display only (so start=1 numbers from one) without changing where the loop actually reads in the list. The reason to prefer it over a hand-rolled counter is not speed, it is that a separate counter you bump by hand is one more thing to forget to increment, and enumerate() cannot drift out of sync.

python
players = ["Alice", "Bob", "Charlie"]

for player in players:
    print(player)

for i, player in enumerate(players, start=1):
    print(f"{i}. {player}")
# 1. Alice
# 2. Bob
# 3. Charlie

for and enumerate() get full treatment in the Control flow chapter. The short version: for player in players runs once per item, and enumerate() gives you both the position and the value on every iteration.

JunoIterating A for loop walks the list one item at a time, and the name after for becomes that item each turn. When you want the position too, enumerate() hands you both, so you never have to keep a counter yourself. Letting it count for me saved me a pile of off-by-one slips.
JunoIteratingfor item in list visits each item in order. enumerate(iterable, start=0) yields (index, value) pairs, cleaner and less error-prone than a counter you manage by hand. Use start=1 when you want one-based numbering.
JunoIteratingenumerate() yields (index, value) pairs that unpack straight into two names, and start only shifts the displayed counter, not the read position. The win is not speed, it is that a manual counter can drift out of sync and this one cannot.

Nested lists

A list can contain other lists. This is how you represent a grid or a table: a list of rows, each row being a list of values. Two sets of square brackets access an item: the first picks the row, the second picks the column.

Nested lists are lists of list references. Each inner list is an independent object. Access with chained subscripts: grid[row][col]. Mutating an inner list affects the outer list because the outer list holds a reference to the same object.

A nested list is not a real 2D array. The outer list holds references to inner lists, each its own independent object, so rows can be different lengths and hold different types. grid[1][2] is two lookups: pick the row, then index into it. The consequence that catches people: a shallow copy of the outer list (the kind .copy() makes) duplicates the outer container but still points at the same inner lists, so a change to an inner row shows up in both copies. The mutability section below makes that concrete.

python
grid = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9],
]

grid[0]       # [1, 2, 3]
grid[1][2]    # 6  (row 1, column 2)
JunoNested lists A list can hold other lists, which is how you build a grid or table: a list of rows, each row a list. Two sets of brackets reach inside, grid[1][2], the first picks the row, the second picks from it.
JunoNested lists Nested lists are lists of references to other lists, each inner list its own object. Reach in with chained subscripts, grid[row][col]. Because the outer list only holds references, mutating an inner list shows up through every name pointing at it.
JunoNested lists Not a true 2D array: the outer list holds references, so inner rows can vary in length and type, and grid[1][2] is two lookups. A shallow copy duplicates the outer list but shares the inner ones, which is the gotcha the next section lands on.

Mutability: the gotcha

This surprises almost everyone. Assigning a list to a new variable does not make a copy. Both names point at the same list. Change one and you change the other. To get an independent copy, you have to ask for one explicitly.

List assignment copies the reference, not the object. Both names point at the same underlying list. Mutations through either name affect the same data. When you need independent data, copy explicitly with .copy(), list(), or a full slice [:].

b = a points a second name at the same list object, so any change through b lands on the object a sees too. .copy() and a[:] make a shallow copy: a new outer list holding the same element references the original did. For a flat list of immutable values (numbers, strings, things that cannot change in place) that is all you need. For nested lists it is a trap, because the inner lists are still shared between the original and the copy, so editing a row through one shows up in the other.

python
a = [1, 2, 3]
b = a            # b is not a copy; it points at the same list

b.append(4)
print(a)         # [1, 2, 3, 4]  (changed: a and b are the same list)
python
b = a.copy()    # independent copy
b = list(a)     # same result
b = a[:]        # also the same

# Nested lists still share their inner objects:
matrix = [[1, 2], [3, 4]]
copy = matrix.copy()

copy[0].append(99)
print(matrix)   # [[1, 2, 99], [3, 4]]  (inner list was shared)

For nested structures where you need full independence, copy each inner list manually, or use copy.deepcopy() from the standard library, covered in the Modules chapter.

JunoMutability: the gotchab = a does not make a copy, both names point at the same list, so a change through one shows through the other. For an independent list you have to ask: .copy(), list(a), or a[:]. This one surprises almost everyone the first time, me included.
JunoMutability: the gotcha Assignment copies the reference, not the list, so b = a leaves two names on one object and mutations through either hit the same data. Copy explicitly with .copy(), list(a), or a[:] when you need independence.
JunoMutability: the gotchab = a shares one object; .copy() and a[:] make a shallow copy, a new outer list over the same inner references. Flat lists of immutable values are safe that way, nested ones share their rows, so reach for copy.deepcopy() when you need the inside independent too.

More methods

MethodWhat it does
.append(item)Add to the end
.insert(i, item)Insert at position i
.extend(iterable)Add all items from an iterable
.remove(value)Remove first occurrence of value
.pop(i)Remove and return item at position i (default: last)
.clear()Remove all items
.index(value)Position of first occurrence
.count(value)Number of occurrences
.sort()Sort in place
.reverse()Reverse in place
.copy()Return a shallow copy

In practice

Building a score tracker: add results, sort them, and print a summary.

python
scores = []

scores.append(87)
scores.append(54)
scores.append(92)
scores.append(67)
scores.append(45)

scores.sort(reverse=True)

print(f"Ranked scores: {scores}")
print(f"Highest: {scores[0]}")
print(f"Lowest:  {scores[-1]}")
print(f"Average: {sum(scores) / len(scores):.1f}")
print(f"Top 3:   {scores[:3]}")

Two parallel lists of names and scores: find the top performer and print ranked results.

python
names = ["Alice", "Bob", "Carol", "Dave"]
scores = [87, 74, 92, 55]

best_score = max(scores)
best_index = scores.index(best_score)
best_player = names[best_index]

print(f"Top player: {best_player} ({best_score})")
print(f"Average:    {sum(scores) / len(scores):.1f}")

ranked = sorted(scores, reverse=True)
print(f"Distribution (ranked): {ranked}")

for rank, score in enumerate(ranked, start=1):
    print(f"  Rank {rank}: {score}")

Demonstrating the difference between aliasing and copying, and between shallow and deep copies of nested lists.

python
# Aliasing: b is not a copy
a = [1, 2, 3]
b = a
b.append(4)
print(a)    # [1, 2, 3, 4]  (same object)

# Shallow copy: outer list is independent, inner lists are shared
matrix = [[1, 2, 3], [4, 5, 6]]
shallow = matrix.copy()
shallow[0].append(99)
print(matrix)    # [[1, 2, 3, 99], [4, 5, 6]]  (inner list shared)

# Manual deep copy with a for loop (no imports needed)
matrix = [[1, 2, 3], [4, 5, 6]]
deep_copy = []
for row in matrix:
    deep_copy.append(row[:])    # copy each inner list explicitly

deep_copy[0].append(99)
print(matrix)    # [[1, 2, 3], [4, 5, 6]]  (unchanged)