Lists
Python's workhorse collection — append, slice, sort, and the shared-reference gotcha that bites every programmer exactly once.
What you'll learn
- The methods that actually matter — append, extend, insert, pop, remove
- sort vs sorted, and why key= is the most useful argument in the language
- Why a = b never copies a list, and the copy that fixes the aliasing bug
- When a list is the wrong tool, and a set is right
Before you start
If Python had a mascot, it would be the list. Lists are ordered, they are mutable — changeable after they are made — and they will hold anything you put in them: numbers, strings, dictionaries, even other lists. You will build one, change one, or walk through one in very nearly every program you ever write, so it is worth knowing them deeply rather than by habit.
Creating and growing a list
A list starts empty and grows. The two ways to add catch people out, so watch the difference between adding one item and adding several:
leaderboard = [] # empty
leaderboard.append("alice") # add one item to the end
leaderboard.append("bob")
leaderboard.extend(["chen", "dani"]) # add EACH element of an iterable
leaderboard.insert(0, "winner") # insert at a specific index
print(leaderboard)
leaderboard.remove("bob") # remove the first matching value
last = leaderboard.pop() # remove AND return the last item
print("removed:", last)
print(leaderboard)
['winner', 'alice', 'bob', 'chen', 'dani']
removed: dani
['winner', 'alice', 'chen']
The distinction to fix in memory: append adds its argument as a single item, while extend adds every element of an iterable. The classic mistake is calling append with a list — lb.append([1, 2]) gives you a list nested inside your list, which is almost never what you meant. When you want to merge in several items, reach for extend.
Indexing and slicing
Lists use exactly the [start:stop:step] slicing you met with strings — same rules, same exclusive stop, same negative indices counting from the right:
scores = [42, 58, 71, 89, 95, 100]
print(scores[0]) # first
print(scores[-1]) # last
print(scores[1:4]) # positions 1, 2, 3
print(scores[:3]) # first three
print(scores[-2:]) # last two
print(scores[::2]) # every other element
print(scores[::-1]) # a reversed copy
42
100
[58, 71, 89]
[42, 58, 71]
[95, 100]
[42, 71, 95]
[100, 95, 89, 71, 58, 42]
One quiet but important fact: slicing returns a new list and never disturbs the original. That is why scores[::-1] is a tidy one-liner for “give me a reversed copy” without touching scores itself.
Sorting — sort versus sorted
There are two ways to order a list, and the difference between them is exactly the difference between changing a thing and making a new thing:
list.sort()sorts the list in place and returnsNone.sorted(iterable)returns a new sorted list and leaves its input untouched.
results = [
("alice", 87),
("bob", 42),
("chen", 95),
("dani", 71),
]
# Sort by score, highest first. The key= argument is what makes this sing.
ranked = sorted(results, key=lambda r: r[1], reverse=True)
for name, score in ranked:
print(f"{name:<8} {score}")
print()
# Sort the original in place, this time by name.
results.sort(key=lambda r: r[0])
print(results)
chen 95
alice 87
dani 71
bob 42
[('alice', 87), ('bob', 42), ('chen', 95), ('dani', 71)]
The star of that example is key=. It is a function applied to each element to produce the value Python should compare — sort by a tuple’s second field, by the length of a string, by a date pulled out of a record, by anything you can compute. It is, with little exaggeration, the most useful single argument in the language.
The aliasing gotcha — a = b does not copy
Here is the bug that bites everyone exactly once. You want a snapshot of a list before you change it, so you write season_2 = season_1 — and then watch your “snapshot” change underneath you:
season_1 = ["alice", "bob", "chen"]
# WRONG — this does not copy. season_2 is just a second NAME for the same list.
season_2 = season_1
season_2.append("dani")
print("season_1:", season_1)
print("season_2:", season_2)
print("same object?", season_1 is season_2)
season_1: ['alice', 'bob', 'chen', 'dani']
season_2: ['alice', 'bob', 'chen', 'dani']
same object? True
Appending through season_2 changed season_1, because there was only ever one list with two names pointing at it. To get a genuinely separate list, you must ask for a copy — and the picture below shows precisely what changes when you do:
.copy() makes a second list. Mutation only stays separate on the right.So to take a real snapshot, copy explicitly — and pick the depth you need:
season_2 = season_1.copy() # shallow copy
season_2 = list(season_1) # also a shallow copy
season_2 = season_1[:] # slice copy — works, older style
import copy
season_2 = copy.deepcopy(season_1) # recursive — for nested structures
A first taste of comprehensions
The most Pythonic way to build a new list from an existing one is the list comprehension. A whole lesson is coming on these, but you will see them so often that a first taste belongs here:
scores = [42, 58, 71, 89, 95, 100]
# Boost everyone by 5, capped at 100.
boosted = [min(s + 5, 100) for s in scores]
print(boosted)
# Keep only the passing scores while you are at it.
passing = [s for s in scores if s >= 60]
print(passing)
# Clean a messy column of names in one pass.
raw = [" Alice ", "BOB", "Chen\n", " dani"]
clean = [name.strip().lower() for name in raw]
print(clean)
[47, 63, 76, 94, 100, 100]
[71, 89, 95, 100]
['alice', 'bob', 'chen', 'dani']
Read a comprehension [f(x) for x in xs if cond(x)] straight across, like a sentence: “f of x, for each x in xs, where the condition holds.” That left-to-right reading is the whole trick.
Methods you will use often
| Method | What it does |
|---|---|
lst.append(x) | add x to the end |
lst.extend(iterable) | add every element of an iterable |
lst.insert(i, x) | insert x at index i |
lst.remove(x) | remove the first occurrence of x |
lst.pop(i=-1) | remove and return the element at i |
lst.index(x) | find the index of the first x |
lst.count(x) | how many times x appears |
lst.reverse() | reverse in place |
lst.sort(key=, reverse=) | sort in place |
lst.clear() | empty the list |
len(lst) | number of elements |
x in lst | membership test — scans every element, so O(n) |
That last row hides a real performance lesson. A membership test x in lst walks the list from the front until it finds a match, so on a list of thousands it is slow when you do it repeatedly. If you keep asking “is this in here?”, a set answers in roughly constant time because it uses a hash table — and that is a lesson coming up shortly.
In one breath
- Lists are ordered and mutable;
appendadds one item,extendadds each element of an iterable. - Slice with
[start:stop:step]; slicing returns a new list and never mutates the original. sort()reorders in place and returnsNone;sorted()returns a new list —key=orders by anything you can compute.a = bbinds a second name to one list; copy with.copy(),list(a),a[:], orcopy.deepcopyfor nesting.- Repeated
x in lstis O(n) — switch to asetwhen membership is the hot path.
Practice
Quick check
What’s next
Lists are for collections that change. When you want a fixed, hashable record instead — an (x, y) coordinate, a row pulled from a table — the right tool is the tuple, and that is next.
Practice this in an interview
All questionsenumerate() pairs each element with its index without maintaining a manual counter. zip() pairs elements from multiple iterables together. Both return lazy iterators, so they compose efficiently. The key trap with zip() is silent truncation when iterables differ in length.
The optimal solution is O(n) using a set to track seen elements and a second set to collect duplicates, avoiding any nested iteration. A Counter-based approach is equally O(n) and often more readable.
For a single level of nesting, a list comprehension or itertools.chain.from_iterable is idiomatic and O(n). For arbitrary depth, recursion or an explicit stack is required. The right choice depends on whether the structure is known at write time.
Slicing uses the syntax `seq[start:stop:step]` and returns a new list containing elements from index `start` up to but not including `stop`, stepping by `step`. Negative indices count from the end; a negative step reverses direction. Omitted parts default to the beginning, end, or step of 1.
Python lists are heterogeneous, pointer-based, and general-purpose. NumPy arrays are homogeneous, stored as contiguous typed memory, and support vectorised operations that run at C speed. For numerical work on more than a few hundred elements, NumPy is almost always faster and more memory-efficient.
Lists are mutable sequences; tuples are immutable. Use a tuple when the collection of items is fixed by meaning — coordinates, RGB values, function return values — and a list when the collection will grow, shrink, or be modified in place. Immutability also makes tuples hashable, so they can serve as dict keys or set members.
Reversing a list is O(n) whether you use slice notation or list.reverse(). Deduplication is O(n) with a set conversion but O(n²) if you check membership against a list. Understanding when order must be preserved changes which tool to reach for.
A shallow copy creates a new container but populates it with references to the same inner objects. A deep copy creates a new container and recursively copies every nested object. The difference only matters when the data structure contains mutable nested objects — for flat structures of immutables, shallow copy is sufficient and faster.
Use sorted() with a key= lambda to produce a new sorted list, or list.sort() to sort in place. Both use Timsort and run in O(n log n). sorted() works on any iterable and returns a new list; list.sort() operates in place and returns None.