Modules/Python/ Collections

Lists and Indexing

One name for many values, kept in order. Indexing reaches in by position - and counting from zero is the part that catches everyone once.

Overview

Start here

A list holds several values in a fixed order under one name. You write it with square brackets and commas. The order you put things in is the order they stay in, which is what separates a list from the unordered collections you will meet later.

A list is ordered, and starts at zero

Positions are counted from 0, so the third item is at index 2. A negative index counts back from the end.

Python 3
Output

                            

Lists can change after you make them

Unlike a string, a list is mutable: you can replace an item, add to the end, or remove one, and the same list object changes.

Python 3
Output

                            

How a list is laid out

Index 0 is the first item. The last item is at len(x) - 1, or just x[-1].
Asking for an index that does not exist raises IndexError. That is Python refusing to guess, not a crash to be afraid of.
append adds one item to the end. remove deletes the first match by value, not by position.
sorted(x) returns a new sorted list and leaves the original alone. x.sort() reorders it in place and returns nothing - a classic source of None surprises.

Lists and Indexing: A Practical Guide

A row of boxes, numbered from zero.

Why counting starts at zero

The index is an offset from the start, not a position. The first item is zero steps from the beginning, the second is one step, and so on.

That framing makes the rest consistent. scores[0:3] means "start at offset 0, stop before offset 3" — three items, and the length of the slice is simply stop - start. Adjacent slices [0:3] and [3:6] neither overlap nor leave a gap. None of that arithmetic works as cleanly with one-based indexing, which is why the convention won.

An ordered, changeable collection

A list holds items in order, allows duplicates, and can be changed after it is created.

scores = [88, 92, 79, 95, 61]

scores[0]        # 88   - first item
scores[-1]       # 61   - last item, without knowing the length
len(scores)      # 5
scores[2] = 80   # lists are mutable: item 2 is now 80

Negative indexing is one of Python's genuinely nice ideas: -1 is the last element, -2 the second to last. No len(x) - 1 arithmetic, and no off-by-one errors from writing it.

An index past the end raises IndexError rather than returning None, which is deliberate — a silent None would propagate somewhere far away before failing.

Slicing: taking a piece

letters = ['a', 'b', 'c', 'd', 'e', 'f']

letters[1:4]     # ['b', 'c', 'd']   - from 1, up to but not including 4
letters[:3]      # ['a', 'b', 'c']   - from the start
letters[3:]      # ['d', 'e', 'f']   - to the end
letters[-2:]     # ['e', 'f']        - the last two
letters[::2]     # ['a', 'c', 'e']   - every second item
letters[::-1]    # ['f', 'e', ... ]  - reversed
letters[:]       # a shallow copy of the whole list

The stop is always exclusive. That is the single rule behind every slicing question, and once it is internalised the rest follows.

Slicing never raises IndexError. letters[10:20] on a six-item list returns [] rather than failing, which is convenient and occasionally hides a bug.

The methods you will actually use

items = [3, 1, 2]

items.append(4)         # [3, 1, 2, 4]     - add one item to the end
items.extend([5, 6])    # [3, 1, 2, 4, 5, 6] - add several
items.insert(0, 0)      # [0, 3, 1, 2, ...]  - insert at a position
items.remove(3)         # remove the first 3 by value; ValueError if absent
last = items.pop()      # remove and return the last item
items.sort()            # sorts IN PLACE, returns None
items.reverse()         # reverses in place

ordered = sorted(items)          # returns a NEW sorted list
backwards = sorted(items, reverse=True)
by_length = sorted(words, key=len)

The sort()/sorted() distinction is the classic beginner trap: items = items.sort() sets items to None, because sort() returns nothing. Methods that change a list in place return None by convention, precisely so this mistake fails loudly.

Try it yourself

  1. Run the first editor. Check that colours[0] is red and colours[2] is blue - the third one.
  2. Ask for one too far. Add print(colours[4]) and run. Four items, so the last index is 3: Python raises IndexError rather than inventing a value.
  3. Run the second editor and watch the list change. The same list is printed three times and differs each time, because the methods modify it in place.
  4. Compare the last two lines. sorted(stack) prints in order, but the following print(stack) shows the original untouched. Swap in stack.sort() and the difference is obvious.

Worth remembering

A list is an ordered, changeable row of values indexed from zero. Reading past the end raises IndexError instead of guessing, and the difference between a method that returns a new list and one that edits the original is worth learning early - it explains most beginner bugs involving None.

Copying, and the aliasing trap

a = [1, 2, 3]
b = a             # NOT a copy - another name for the same list
b.append(4)
print(a)          # [1, 2, 3, 4]

c = a.copy()      # a real (shallow) copy
c.append(5)
print(a)          # unchanged

"Shallow" matters when the list contains other mutable objects:

grid = [[0, 0], [0, 0]]
flat = grid.copy()
flat[0].append(9)
print(grid)       # [[0, 0, 9], [0, 0]]  - the inner lists are still shared

Use copy.deepcopy() when you need the nested objects copied too.

The same aliasing rule produces the most notorious Python gotcha of all:

board = [[0] * 3] * 3      # three references to ONE row
board[0][0] = 1
print(board)               # [[1,0,0], [1,0,0], [1,0,0]]

board = [[0] * 3 for _ in range(3)]   # three separate rows - correct

List comprehensions

The idiomatic way to build a list from another iterable:

squares  = [n * n for n in range(10)]
evens    = [n for n in numbers if n % 2 == 0]
upper    = [name.upper() for name in names]
pairs    = [(x, y) for x in range(3) for y in range(3)]

Read it as "give me this, for each item, where this condition holds". It is shorter than the loop-and-append version, and generally faster, because the append is done in C rather than through the interpreter.

Keep them to one line of logic. A comprehension with two conditions, a nested loop and a conditional expression is harder to read than the loop it replaced — at which point write the loop.

For very large sequences, a generator expression (n * n for n in range(10_000_000)) produces items lazily and uses almost no memory.

What list operations cost

A list is an array of references underneath, and that single fact predicts the cost of everything you can do to one.

Cheap, and independent of size. Indexing items[i], assigning to a position, len(), and append. Appending is occasionally slower when the underlying array has to grow, but Python over-allocates so that averages out to constant.

Proportional to length. insert(0, x) and pop(0), because everything after the insertion point has to shift along. remove(x) and x in items, because both scan until they find a match. del items[0], for the same shifting reason.

The one that catches people is the combination: a loop over n items that does x in some_list inside it is doing n scans, which is quadratic even though nothing about the code looks nested. Converting that list to a set once, before the loop, is the standard fix and often the whole fix.

The other is queue-shaped work. Repeatedly taking from the front of a list is proportional to length on every step. collections.deque is built for it:

from collections import deque

d = deque([1, 2, 3])
d.appendleft(0)
print(list(d))
[0, 1, 2, 3]

appendleft and popleft are constant time, where the list equivalents are not. If a list is being used as a queue, a deque is the right container and the change is usually two lines.

Removing items safely

Removing while iterating is the mistake that produces wrong output rather than an error, because the iterator walks by position and removal shifts everything after it left.

The reliable answer is almost always to build a new list instead of editing the old one:

rows = [1, 2, 2, 3]
print([x for x in rows if x != 2])
[1, 3]

A comprehension states the condition for *keeping* an item, which is easier to get right than a condition for removing one, and it cannot skip anything because nothing shifts underneath it.

When the removal genuinely has to happen in place — because other code holds a reference to that exact list — assign back into a full slice: items[:] = [x for x in items if keep(x)]. That replaces the contents rather than rebinding the name, so every holder sees the change.

The other two options are worth knowing and worth reaching for less. Iterating over a copy, for x in items[:], works because the copy is not being modified. Walking backwards by index works because shifting only affects positions already visited. Both are correct and both are more to explain than the comprehension.

Sorting, in place and not

Two spellings, one difference, and it is the source of a classic beginner bug.

items.sort() reorders the list in place and returns None. sorted(items) leaves the original alone and returns a new list. So items = items.sort() replaces the list with None, and the None is deliberate — every in-place method in the standard library returns it, precisely so this mistake fails loudly rather than silently appearing to work.

Both take the same two arguments. key is a function applied to each item to decide what to compare: key=len sorts by length, key=str.lower sorts case-insensitively, key=lambda r: r["score"] sorts records by a field. reverse=True flips the whole ordering.

A tuple key gives tiebreaks, and it is worth having in your fingers: key=lambda r: (-r["score"], r["name"]) sorts by score descending and breaks ties by name ascending. The minus sign reverses one field only, which reverse=True cannot do because it reverses everything.

The sort is stable, meaning items that compare equal keep their original relative order. That guarantee is what makes sorting twice work for a two-level sort, and it is a property you can rely on rather than an accident of the implementation.

Building a list, and the ways that go wrong

Three ways to build a list, and one that quietly does not.

Append in a loop when each step does real work or has a condition too complex for one line. It is the plainest shape and it is never wrong.

A comprehension when the loop's only job is to produce items. [f(x) for x in items if keep(x)] states the result on the first line and leaves nowhere for anything else to hide.

Multiplication for a flat list of a repeated immutable value. [0] * 5 gives five zeros and is perfectly safe, because integers cannot be mutated.

The one that goes wrong is multiplication with a mutable item. [[0] * 3] * 3 does not build three rows; it builds one row and stores three references to it, so writing into board[0][0] appears to write into all three. The inner [0] * 3 is fine — it is the outer multiplication that duplicates a reference rather than the object.

The comprehension version, [[0] * 3 for _ in range(3)], evaluates the inner expression afresh on every pass, so there really are three lists. The same rule explains the mutable default argument: one object, created once, shared everywhere it appears.

The general test is whether the thing being repeated can change. Repeat numbers, strings and tuples freely; build anything mutable with a comprehension.

Questions people ask

What is the difference between append and extend? append adds one item — [1,2].append([3,4]) gives [1, 2, [3, 4]]. extend adds each item of an iterable, giving [1, 2, 3, 4].

How do I remove an item? remove(value) by value, pop(index) by position (returning it), del items[i] by position, or a comprehension to filter several at once.

Why should I not modify a list while looping over it? Because the iterator's position and the list's indices go out of step, and items get skipped. Build a new list instead.

Are lists slow? Indexing and appending are fast. in and remove scan the whole list, so for membership testing on large collections use a set.

How do I flatten a nested list? [item for row in grid for item in row] — the loops read left to right, outer first.

What is the difference between a list and a tuple? Tuples are immutable and can be dictionary keys; lists are mutable and cannot.

Does slicing copy the items too? No. A slice makes a new list holding the same objects, which is why editing a nested list through a slice is visible in the original.

Recap in one screen

  • Lists are ordered, mutable and allow duplicates; indexing starts at 0 and -1 is the last item.
  • A slice [start:stop] includes the start and excludes the stop; slices never raise IndexError.
  • In-place methods (sort, append, reverse) return None; sorted() returns a new list.
  • Assigning a list to another name shares it — copy explicitly, and deep-copy nested structures.
  • Build lists with comprehensions, and use a set when you mostly need membership tests.

Where lists fit among the other collections

TypeOrderedChangeableDuplicatesGood at
listYesYesYesOrdered data you will modify
tupleYesNoYesFixed records; dictionary keys
setNoYesNoMembership tests; removing duplicates
dictYes (insertion)YesKeys uniqueLooking things up by name

The row that changes how code performs is the set. Checking x in my_list looks at every element until it finds a match — on a list of 100,000 items that is up to 100,000 comparisons. Checking x in my_set hashes the value and looks in one place, taking about the same time whatever the size.

seen = set()
for row in rows:
    if row.id in seen:        # fast even with millions of ids
        continue
    seen.add(row.id)

Converting a list to a set also deduplicates it in one step: unique = list(set(items)) — at the cost of losing the order. To deduplicate and keep order, list(dict.fromkeys(items)) works, because dictionaries remember insertion order.

Common mistakes

  • items = items.sort(). sort() returns None, so this destroys the list. Use items.sort() alone, or new = sorted(items).
  • Modifying a list while iterating over it. Removing items shifts the indices under the iterator and rows get skipped. Build a new list instead: keep = [x for x in items if not stale(x)].
  • Using a list as a default argument. def add(item, target=[]): creates the list once, at definition time, and every call shares it. Use target=None and create the list inside.
  • [[0] * 3] * 3 for a grid. Three references to one row. Use a comprehension.
  • Assuming b = a copies. It does not, and the bug appears far from the assignment.
  • Using in on a large list in a loop. That is an accidental quadratic algorithm; a set makes it linear.

Check yourself

0 of 3

Answer without scrolling back up.

  1. colours = ["red", "green", "blue"]. What is colours[1]?

  2. That same three-item list. What does colours[3] do?

  3. After nums = [3, 1, 2] and sorted(nums), what is nums?

Cheat sheet

Lists and Indexing

A list holds several values in a fixed order under one name. You write it with square brackets and commas. The order you put things in is the order they stay in, which is what separates a list from the unordered collections you will meet later.

PYTHON · vizlearn.in/python/lists_and_indexing.html

Further reading

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.