Lists: indexing, slicing, methods
Overview
A list is an ordered, mutable sequence that can hold any Python objects. Create one with square brackets and access elements by zero-based index — negative indexes count from the end, and reading past the end raises `IndexError`: items = ['a', 'b', 'c', 'd'] items[0] # 'a' — first items[-1] # 'd' — last items[10] # IndexError — indexing past the end raises Slicing returns a NEW list and never modifies the original. Unlike indexing, out-of-range slice bounds are silently clamped, not errors: items[1:3] # ['b', 'c'] — index 1 up to (not including) 3 items[:2] # ['a', 'b'] — omitted start means 0 items[2:] # ['c', 'd'] — omitted stop means to the end items[::-1] # ['d', 'c', 'b', 'a'] — reversed copy items[1:99] # ['b', 'c', 'd'] — clamped, no error Key methods — each one mutates the list in place and returns `None`, except `pop()` and `index()` which return a value: - `append(x)` — add `x` as ONE item at the end (even if `x` is itself a list) - `extend(iterable)` — add each item from an iterable; `lst.extend([4, 5])` adds two items, `lst.append([4, 5])` adds one nested list - `insert(i, x)` — insert `x` before index `i`, shifting the rest right - `pop(i)` — remove AND return the item at index `i` (default: the last one); raises `IndexError` on an empty list - `remove(x)` — remove the first occurrence of `x`; raises `ValueError` if `x` is absent - `index(x)` — return the first index of `x`; raises `ValueError` if absent - `sort()` — sort in place and return `None`; use `sorted(lst)` when you need a new sorted list back Copying: `items[:]`, `items.copy()`, and `list(items)` are three spellings of the same operation — a shallow copy. You get a new outer list, but every slot still points to the SAME inner objects. With flat lists of numbers or strings that never matters; with nested mutable data it bites: a = [[1, 2], 20, 30] c = a[:] # new outer list, same inner references c[1] = 999 # REPLACES a slot — only c changes c[0].append(99) # mutates the shared INNER list print(a) # [[1, 2, 99], 20, 30] — a changed too! The rule: replacing a slot (`c[1] = 999`) touches only the copy; mutating the object INSIDE a slot (`c[0].append(99)`) is visible through both lists. The full fix (`copy.deepcopy`) is covered later in shallow-vs-deep-copy — for now, remember that slicing copies the row of labels, not the objects they point to. Think of a list as a numbered row of stickers, each pointing at an object: indexing reads one sticker, slicing photocopies a stretch of stickers (the objects themselves are not copied), and the mutating methods rearrange or replace stickers in place.
Lists are the default container for ordered data in Python: API response arrays, database result sets, pipeline stages, log entries. Knowing slicing and the right method prevents off-by-one bugs and unexpected None returns, and knowing that every list copy is shallow prevents the classic bug where mutating a 'copy' silently corrupts the original payload.
Where used: FastAPI response arrays of records, SQLModel query result lists, Collecting pipeline stage outputs
Why learn this
- Slicing API result lists (page = results[offset:offset+limit]) for pagination in FastAPI endpoints
- Using pop() and append() correctly — both return None by accident is one of the most common beginner bugs
- Copying a list before mutating it — and knowing when a shallow copy is NOT enough to protect nested data
Code walkthrough
log = ['INFO', 'DEBUG', 'ERROR', 'INFO', 'WARN']
print(log[0])
print(log[-1])
print(log[1:3])
log.append('ERROR')
log.sort()
print(log)
Focus: log[1:3] returns a new list without changing log; append and sort mutate log in place — the final print shows both effects accumulated.
Aha moment
batches = [['job-1', 'job-2'], 'queued', 'low-priority']
snapshot = batches[:]
snapshot[0].append('job-99')
snapshot[1] = 'running'
print(batches)
print(snapshot)
Prediction: snapshot was made with [:] — a copy. What does the first print show for batches?
Common guess: [['job-1', 'job-2'], 'queued', 'low-priority'] — the copy is independent, so batches is untouched
Slicing is a SHALLOW copy: a new outer list whose slots point at the same inner objects. snapshot[0].append() mutates the shared inner list, so batches sees 'job-99' too; snapshot[1] = 'running' only rebinds a slot in the copy, so batches keeps 'queued'.
Common mistakes
- Assigning the return value of append or sort: result = items.append(x) sets result to None because append mutates in place and returns None. These mutating methods return nothing useful — call them as statements.
- Slicing thinking it modifies the original: items[1:3] returns a NEW list — the original is unchanged. To replace a slice in place, assign to the slice: items[1:3] = ['x', 'y'].
- Trusting a slice copy to protect nested data: c = a[:] (or a.copy()) copies only the outer list — inner lists and dicts are still shared. Mutating c[0] in place (e.g. c[0].append(99)) changes a too. Use copy.deepcopy() when the list holds mutable items you plan to mutate.
- Using remove() without checking membership first: items.remove(x) raises ValueError if x is not in the list. Guard with if x in items: or catch ValueError.
- Confusing append() with extend(): lst.append([4, 5]) adds the whole list as ONE nested element; lst.extend([4, 5]) adds two separate items. If len() grew by 1 when you expected 2, you appended an iterable you meant to extend.
Glossary
- mutable
- An object whose internal state or data can be changed after it is created, e.g. `items = [1, 2]; items.append(3)` modifies the same list.
- shallow copy
- A copy that duplicates the outer container but keeps references to the same inner objects — `a[:]`, `a.copy()`, and `list(a)` all produce one.
- pagination
- The process of dividing a large set of results into smaller, more manageable pages, typically used in web APIs, e.g. `results[0:10]` for page 1, `results[10:20]` for page 2.
Recall questions
- What does a negative index like -1 mean on a list?
- Does slicing a list modify the original list?
- What does list.pop(i) return?
- What does list.append() return?
- Is a.copy() the same operation as a[:]?
- After c = a[:], which change is visible through BOTH lists: replacing a slot (c[0] = x) or mutating the object inside a slot (c[0].append(x))?
Understanding checks
What does this code print?
['b', 'c']
Slicing a list (`items[1:]`) creates a *new* list. Modifying the original `items` list by appending 'd' does not affect the `subset` list.
Why does this code print `None` instead of `['hello', 'world']`?
Because `sort()` mutates the list in place and returns `None`.
List methods like `sort()` and `append()` change the original list rather than returning a new one. To fix it, call `words.sort()` as a statement, then print `words`.
What does this print — 3 or 4?
3
`append()` adds its argument as ONE item, so the list `['clean', 'store']` becomes a single nested element. To add each item separately, use `extend()` — then `len()` would be 4.
Practice tasks
Fix the pagination logic
The `paginate` function currently returns all items starting from `page * page_size`, but it doesn't limit the returned list to `page_size` elements. Modify the slice so it correctly returns a list of exactly `page_size` elements for the given page.
Challenge
Request queue manager
Write enqueue(queue, item) and dequeue(queue) functions that treat a list as a FIFO queue. enqueue appends to the end; dequeue removes and returns from the front, returning None when the queue is empty (do not raise). Print three enqueue calls, then dequeue until empty.
Continue learning
Previous: Primitive types: int, float, bool, str
Previous: Mutable vs immutable objects
Previous: for and while loops
Next: List Comprehensions
Next: Shallow vs Deep Copy