Python Slicing: Syntax, Behavior, and Edge Cases
Understand python slicing: start, stop, and step syntax, negative indices, slice assignment, copy semantics, and the edge cases that cause bugs.
Python slicing lets you extract or modify contiguous portions of sequences such as lists, strings, and tuples using the sequence[start:stop:step] syntax. It appears in almost every Python codebase, yet its details—exclusive stops, negative indices, step direction, and copy semantics—are a common source of subtle bugs.
Basic Slicing Syntax
The simplest form is seq[start:stop], which returns elements from index start up to, but not including, index stop.
numbers = [10, 20, 30, 40, 50] print(numbers[1:3]) # [20, 30]
The stop index is exclusive. numbers[1:3] selects elements at positions 1 and 2 only. If start is omitted, slicing begins at index 0. If stop is omitted, it runs to the end of the sequence.
print(numbers[:3]) # [10, 20, 30] print(numbers[2:]) # [30, 40, 50]
Both bounds are optional, and the defaults are the beginning and end of the sequence respectively.
Negative Indices
Negative indices count backward from the end of the sequence. Index -1 is the last element, -2 is the second-to-last, and so on.
numbers = [10, 20, 30, 40, 50] print(numbers[-2:]) # [40, 50] print(numbers[:-1]) # [10, 20, 30, 40] print(numbers[-3:-1]) # [30, 40]
Combining negative bounds with the default start or stop is a common way to trim trailing or leading elements without computing the sequence length.
The Step Parameter
The full slice syntax is seq[start:stop:step]. The step controls which elements are selected between start and stop.
numbers = [10, 20, 30, 40, 50] print(numbers[::2]) # [10, 30, 50] print(numbers[1::2]) # [20, 40]
A negative step traverses the sequence in reverse. seq[::-1] is the idiomatic way to reverse any sequence.
print(numbers[::-1]) # [50, 40, 30, 20, 10]
When the step is negative, start and stop are interpreted relative to the reversed traversal. For example, numbers[4:1:-1] returns [50, 40, 30], starting at index 4 and moving backward to, but not including, index 1.
A step of zero raises ValueError: slice step cannot be zero.
Slicing Different Sequence Types
Lists, strings, and tuples all support slicing, and each returns a value of the same type.
text = "python" print(text[2:5]) # "tho" tuple_data = (1, 2, 3, 4) print(tuple_data[1:3]) # (2, 3)
Slicing a string returns a new string, not a list of characters. Slicing a tuple returns a tuple. This type preservation matters when you chain operations: text[2:5].upper() works because text[2:5] is still a string.
Slice Assignment
Lists support slice assignment, which replaces the selected range with the elements of an iterable.
numbers = [10, 20, 30, 40, 50] numbers[1:3] = [200, 300] print(numbers) # [10, 200, 300, 40, 50]
The replacement iterable does not need to match the slice length. Assigning a shorter list shrinks the list; a longer one grows it.
numbers = [10, 20, 30] numbers[1:2] = [100, 110, 120] print(numbers) # [10, 100, 110, 120, 30]
When the slice includes a step, the replacement must have exactly the same length as the slice, because each position is overwritten individually.
numbers = [10, 20, 30, 40, 50] numbers[::2] = [0, 0, 0] print(numbers) # [0, 20, 0, 40, 0]
Slice Objects
The slice() built-in creates a reusable slice object that can be passed to any sequence.
my_slice = slice(1, 4) numbers = [10, 20, 30, 40, 50] print(numbers[my_slice]) # [20, 30, 40]
Slice objects are useful when the same range must be applied across multiple sequences or stored as a configuration value. They also appear in custom classes that implement __getitem__ to handle slice notation explicitly.
reverse_slice = slice(None, None, -1) print(numbers[reverse_slice]) # [50, 40, 30, 20, 10]
Memory and Performance Considerations
Every slice operation creates a new container. For lists, this is a shallow copy: the new list holds references to the same objects, not copies of them. The cost is proportional to the slice length, so slicing a list of one million elements creates a new list of one million references.
large = list(range(1_000_000)) subset = large[500_000:] # new list with 500,000 references
For strings, slicing allocates a new string object. CPython may reuse memory for some single-character strings, but in general each slice is a fresh allocation.
When you need to iterate over a portion of a sequence without materializing a copy, consider itertools.islice for iterators, or iterate over the range directly. For example, for item in large[100:200] builds a 100-element list first; for i in range(100, 200): item = large[i] avoids that allocation.
Slice assignment on a list is also O(n) in the worst case because elements after the slice may need to be shifted to accommodate the new length.
Common Mistakes and Edge Cases
Out-of-range indices do not raise an error. Slicing clamps bounds to the sequence length.
numbers = [10, 20, 30] print(numbers[5:10]) # [] print(numbers[1:100]) # [20, 30]
A full slice seq[:] returns a copy of the entire sequence. This is a common way to duplicate a list, but it is a shallow copy: nested objects are shared.
copy = numbers[:] copy[0] = 99 print(numbers) # unchanged
For nested structures, a shallow copy does not protect inner objects from mutation.
matrix = [[1, 2], [3, 4]] copy = matrix[:] copy[0][0] = 99 print(matrix) # [[99, 2], [3, 4]]
When a slice with a step is assigned, a length mismatch raises ValueError: attempt to assign sequence of size X to extended slice of size Y. This is a deliberate guard because the extended slice cannot change length.
numbers = [10, 20, 30, 40, 50] numbers[::2] = [0, 0] # ValueError: attempt to assign sequence of size 2 to extended slice of size 3