11.2 Indexing and Slicing: start, stop, step, and Negative Indices
Understanding Indexing: Accessing Individual Elements
At its core, indexing is the mechanism for accessing a single element within a list. Python uses zero-based indexing, meaning the first element is at position 0, the second at position 1, and so on. This convention is common in programming languages like C, Java, and JavaScript because it often leads to simpler arithmetic when calculating memory offsets—a list’s name points to the start of the contiguous block of memory holding its elements, so the i-th element is located at that base address plus i steps.
Attempting to use an index greater than or equal to the list’s length (or a negative index with an absolute value greater than the length) raises an IndexError.
my_list = [10, 20, 30, 40, 50]
# Access the first element (index 0)
print(my_list[0]) # Output: 10
# Access the third element (index 2)
print(my_list[2]) # Output: 30
# This will raise an IndexError because the list has only 5 elements.
# print(my_list[5])
Negative Indices: Counting from the End
Python supports negative indexing, a convenient feature that allows you to count backwards from the end of the list. The index -1 refers to the last item, -2 to the second last, and so on. This is implemented internally by simply adding the negative index to the length of the sequence: list[-n] is equivalent to list[len(list) - n]. This syntactic sugar eliminates the need for verbose expressions like list[len(list) - 1] to get the last element.
my_list = ['a', 'b', 'c', 'd', 'e']
# Access the last element
print(my_list[-1]) # Output: e
# Access the second-to-last element
print(my_list[-2]) # Output: d
# Negative indexing can also cause IndexError
# print(my_list[-6]) # This would error
The Slice Operation: start, stop, and step
Slicing is the process of extracting a portion (a sub-list) from a list. The syntax is list[start:stop:step]. It’s crucial to understand that slicing uses half-open intervals: the start index is inclusive, while the stop index is exclusive. This design choice, shared with ranges, avoids off-by-one errors when calculating segment lengths (the length of a slice is simply stop - start). The step value determines the stride between elements; a step of 1 takes every element, 2 takes every other, etc.
Omitting any of these parameters uses sensible defaults: start defaults to 0, stop defaults to len(list), and step defaults to 1. Unlike indexing, slicing is forgiving; it will not raise an IndexError if indices are out of bounds. Instead, it gracefully clips the slice to the actual boundaries of the list.
numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
# Get elements from index 2 up to, but not including, index 6
slice1 = numbers[2:6]
print(slice1) # Output: [2, 3, 4, 5]
# Omitting start (starts from 0)
print(numbers[:4]) # Output: [0, 1, 2, 3]
# Omitting stop (goes to the end)
print(numbers[7:]) # Output: [7, 8, 9]
# Using a step of 2 to get every other element
print(numbers[1:9:2]) # Output: [1, 3, 5, 7]
# Out-of-bounds indices are handled gracefully
print(numbers[0:100]) # Output: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
Advanced Slicing with Negative step
When a negative value is provided for step, the slice operation reverses its traversal direction. It starts at the start index and moves backwards towards the stop index. Because of this reversal, the defaults for start and stop are also inverted: the default start becomes the last element (len(list)-1), and the default stop becomes “before the first element” (conceptually -1, which is why [::-1] works for reversing the entire list).
A common pitfall is getting the start and stop indices wrong when using a negative step. The slice list[5:2:-1] will include indices 5, 4, and 3 because start is inclusive and stop is exclusive, even when moving backwards.
numbers = [0, 1, 2, 3, 4, 5]
# Reverse the entire list
reversed_list = numbers[::-1]
print(reversed_list) # Output: [5, 4, 3, 2, 1, 0]
# Get a reversed slice from index 4 down to index 1 (stops *before* index 1)
partial_reverse = numbers[4:1:-1]
print(partial_reverse) # Output: [4, 3, 2]
# A common mistake. This starts at index 1 and goes backwards. Since it can't go
# backwards to an index beyond 1, it returns an empty list.
empty_slice = numbers[1:5:-1]
print(empty_slice) # Output: []
Slicing and List Internals: Creating Shallow Copies
A critical behavior to understand is that slicing a list returns a new list object. This new list is a shallow copy; it contains references to the same objects as the original slice, not copies of the objects themselves. This is highly efficient for memory but has important implications for mutable elements. If you modify a mutable object (like a nested list) within the slice, that change will be reflected in the original list because both lists hold references to the same underlying object.
original = [1, 2, [3, 4]]
new_slice = original[1:3] # new_slice is [2, [3, 4]]
# Modifying an immutable element in the slice does NOT affect the original.
new_slice[0] = 'changed'
print(original) # Output: [1, 2, [3, 4]] (unchanged)
# Modifying the MUTABLE list element within the slice DOES affect the original.
new_slice[1][0] = 'changed'
print(original) # Output: [1, 2, ['changed', 4]] (changed!)
This behavior makes the slice operation list[:] a common and idiomatic way to create a shallow copy of an entire list, which is useful for creating a new list you can modify without altering the original.