Python Lists — The Basics
Build a real mental model of Python lists: 0-based indexing and slicing, why lists are mutable and shared by reference, how iteration leads to comprehensions, and the shared-row pitfall to steer around.
Introduction
A list is an ordered, changeable collection of values held in a single name. It can contain anything — numbers, strings, dictionaries, even other lists — and each item has a position called its index, counting from 0. Lists are the workhorse of Python: whenever you have *a sequence of things* rather than one thing, you almost certainly reach for a list.
Why a dedicated type? Because programs are full of sequences — the lines of a file, the results of a query, the players in a game — and treating them as one collection lets you count them, loop over them, slice them and rebuild them as a group. Without lists you would name each value separately and lose every one of those abilities.
The mental model is a row of numbered slots you can change while the program runs. The slots are ordered, reachable by number, and the row can grow, shrink or be reordered in place. By the end of this guide you'll be able to reason about how a list is indexed and sliced, why assigning one to another name can surprise you, how iteration turns into comprehensions, and where the one classic pitfall hides.
fruits = ["apple", "pear", "plum"]
# 0 1 2 <- indexes
Lessons
1. Indexing: reaching items by position
Every item has an index — its position, starting at 0. You read or replace an item with square brackets. Python adds a feature many languages lack: negative indices count from the end, so -1 is the last item and -2 the second-to-last, with no length - 1 arithmetic. Unlike a silent None, reading an index that doesn't exist raises an IndexError — Python fails loudly at the point of the mistake, which is usually a kindness.
fruits = ["apple", "pear", "plum"]
fruits[0] # 'apple' (first)
fruits[-1] # 'plum' (last, counting from the end)
fruits[1] # 'pear'
fruits[9] # IndexError: list index out of range
2. Slicing: taking a run of items at once
A slice copies a contiguous run of a list into a new list, written list[start:stop]. The range is *half-open*: it includes start and excludes stop, so fruits[0:2] gives the first two items. Omit either end to mean "from the beginning" or "to the end," and add a third number for a step. The crucial thing to internalise is that a slice always returns a new list — it never aliases the original — which makes list[:] the shortest way to copy a whole list.
nums = [10, 20, 30, 40, 50]
nums[1:3] # [20, 30] (start included, stop excluded)
nums[:2] # [10, 20] (from the start)
nums[2:] # [30, 40, 50] (to the end)
nums[::2] # [10, 30, 50] (every second item)
nums[:] # a full shallow copy
3. Mutability: a list changes in place
Lists are mutable — their contents can change after creation, and most list methods change the list *in place* rather than returning a new one. len() reports the current size and stays in step automatically as you add or remove items. This is the deep difference between a list and a tuple: a tuple is a fixed sequence, while a list is one you edit. The table below groups the common size-changing methods; each mutates the list it's called on.
| Method | What it does |
|---|---|
append(x) | Add x to the end |
insert(i, x) | Insert x at index i (shifts the rest right) |
extend(other) | Add every item from another iterable |
pop(i) | Remove and return the item at i (last if omitted) |
remove(x) | Remove the first item equal to x |
nums = [1, 2, 3]
print(len(nums)) # 3
nums.append(4)
print(len(nums)) # 4 — grew in place
nums.pop(0)
print(nums) # [2, 3, 4] — removed the first item
Because these methods mutate rather than copy, a list is fine to edit freely — right up until it is *shared*, which is the next and most important idea.
4. Lists are shared by reference
A list variable does not hold the list itself; it holds a reference to it — a label pointing at one row of slots in memory. Assigning that variable to another name copies the label, not the row, so both names refer to the *same* list. A change made through one name is visible through the other. This is behind a large share of beginner Python bugs: you "copied" a list, edited the copy, and the original changed too.
original = [1, 2]
alias = original # same list, second name
alias.append(3)
print(original) # [1, 2, 3] — changed through 'alias'
print(alias is original) # True — one list, two references
To edit without disturbing the source, make a real copy first — a full slice list[:] or list(original). Both are shallow: the outer row is new, but any objects (including nested lists) *inside* it are still shared. Keep that caveat in mind — it becomes the pitfall in the last lesson.
source = ["apple", "pear"]
copy = source[:] # or list(source)
copy.append("kiwi")
print(source) # ['apple', 'pear'] — untouched
print(copy) # ['apple', 'pear', 'kiwi']
5. Iterating: visiting each item
To iterate is to visit each item in turn. Python's for item in list hands you the values directly, with no index bookkeeping — cleaner than counting through positions. When you *do* need the index alongside the value, enumerate gives you both. Use a plain loop when you want to *do* something for each item and don't need a result back.
fruits = ["apple", "pear", "plum"]
for fruit in fruits:
print(fruit) # apple, pear, plum
for i, fruit in enumerate(fruits):
print(i, fruit) # 0 apple, 1 pear, 2 plum
But when the loop exists only to build a *new* list — collecting transformed values or keeping some — Python offers a clearer way to say exactly that.
6. Comprehensions: describing the new list
A comprehension builds a new list from an iterable in one readable line: [expression for item in iterable if condition]. It is Python's idiomatic replacement for the map-and-filter loop — instead of creating an empty list and appending in a loop, you *describe* what the new list contains and let Python build it. Read it left to right: the expression is what each item becomes, and the optional if decides which items survive.
squares = [n * n for n in range(1, 6)] # transform each
evens = [n for n in range(10) if n % 2 == 0] # keep some
print(squares) # [1, 4, 9, 16, 25]
print(evens) # [0, 2, 4, 6, 8]
A comprehension always returns a new list and leaves the source untouched, exactly like a slice. Once it clicks, most small map/filter loops collapse into a single expressive line.
7. The shared-row pitfall (the sharp edge)
Here is the classic trap that follows directly from reference semantics. Multiplying a list *repeats the same reference*, it does not make independent copies. So [[0] * 3] * 3 looks like a fresh 3×3 grid but is really three names for one inner row — change one cell and every row changes, because there is only one row.
grid = [[0] * 3] * 3 # looks like three separate rows
grid[0][0] = 9
print(grid) # [[9, 0, 0], [9, 0, 0], [9, 0, 0]] — all changed!
The fix is to build genuinely separate inner lists, which a comprehension does naturally: [[0] * 3 for _ in range(3)]. The reason to know the pitfall is to recognise its unmistakable symptom — "I changed one cell and the whole column moved" — rather than to reproduce it.
That's the whole model. A list is a mutable row of numbered slots; you index and slice it by position; the name holds a reference, not the row; you iterate to read and use a comprehension to build; and the shared-row trap is the one edge to steer around. Every method beyond these is a detail hung on that frame.
Practice
Check your model before moving on. Given nums = [10, 20, 30, 40], what does nums[1:3] return, and does nums change? (Answer: [20, 30], and no — slicing always returns a new list and leaves the source untouched.)
And a reference check: after b = a, calling b.append(9) does what to a? (Answer: a ends in 9 too — they are two names for one list.) Now open the online Python compiler and try it: build a comprehension that keeps the prices above 10 from prices = [19, 5, 42, 8], then sum() them — and confirm prices is unchanged afterward.
Next steps
You have the mental model — now put it to work. Follow how to sort a list in Python for a common task done correctly, or browse runnable Python list examples for copy-paste snippets. Run anything live in the online Python compiler.
Frequently asked questions
How do I get the last item of a Python list?
my_list[-1]. my_list[-2] gives the second-to-last, and so on — no len() - 1 needed.Why did editing my copied list also change the original?
= copies the reference, not the list — both names point at the same row. Copy it first with a slice list[:] or list(original) before editing.What's the difference between append() and extend()?
append(x) adds x as a single element; extend(iterable) adds each item from the iterable. [1].append([2, 3]) gives [1, [2, 3]], while [1].extend([2, 3]) gives [1, 2, 3].What is a list comprehension?
[expr for item in iterable if condition]. It returns a new list and replaces a common map/filter loop with one readable line.Why does [[0]*3]*3 change every row at once?
[[0]*3 for _ in range(3)].