How does Python list slicing work, including step and negative indices?
Slicing uses the syntax `seq[start:stop:step]` and returns a new list containing elements from index `start` up to but not including `stop`, stepping by `step`. Negative indices count from the end; a negative step reverses direction. Omitted parts default to the beginning, end, or step of 1.
How to think about it
Slicing looks trivial, but the edge cases — negative indices, negative steps, out-of-range bounds — trip up a lot of candidates. The cleanest way to explain it is to build up the three parameters in order, and to carry one mental model: the indices sit between elements, not on them.
The full form is seq[start:stop:step], and stop is exclusive — the element at stop is left out, matching range(start, stop). A negative index -k means len(seq) - k, so -1 is the last element. A negative step walks backwards.
data = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
data[2:5] # [2, 3, 4] — stop=5 excluded
data[:4] # [0, 1, 2, 3] — omitted start = beginning
data[6:] # [6, 7, 8, 9] — omitted stop = end
data[:] # a full shallow copy
data[::2] # [0, 2, 4, 6, 8] — every other
data[::-1] # [9, 8, ..., 0] — reversed
data[-3:] # [7, 8, 9] — last three
data[8:2:-1] # [8, 7, 6, 5, 4, 3] — step backwards
A worked example
data = list(range(10))
print("data:", data)
print("[2:5] ->", data[2:5]) # indices 2,3,4
print("[::2] ->", data[::2]) # every other
print("[1::2] ->", data[1::2]) # odd indices
print("[::-1] ->", data[::-1]) # reversed
print("[-3:] ->", data[-3:]) # last three
print("[-3:-1]->", data[-3:-1]) # stop still exclusive
# Out-of-range slices clamp silently — no IndexError
print("[100:] ->", data[100:]) # []
print("[:100] ->", data[:100]) # whole list
# Slice assignment edits in place — and can change the length
nums = [1, 2, 3, 4, 5]
nums[1:3] = [20, 30, 40] # replace two elements with three
print("After slice assign:", nums)
# Strings and tuples slice by the same rules
print("string slice:", "hello world"[6:])
print("tuple slice :", (1, 2, 3, 4)[1:3])
data: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
[2:5] -> [2, 3, 4]
[::2] -> [0, 2, 4, 6, 8]
[1::2] -> [1, 3, 5, 7, 9]
[::-1] -> [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
[-3:] -> [7, 8, 9]
[-3:-1]-> [7, 8]
[100:] -> []
[:100] -> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
After slice assign: [1, 20, 30, 40, 4, 5]
string slice: world
tuple slice : (2, 3)
Two behaviours worth naming. First, slices clamp instead of raising: data[100:] returns [] where the single-index data[100] would throw IndexError — a slice describes a range, so an empty overlap is a legal answer, not an error. Second, slice assignment can resize the list: nums[1:3] = [20, 30, 40] swapped two elements for three, growing the list in place.
The same rules everywhere
Strings and tuples slice identically, each returning a new object of its own type. String slicing is a tidy way to pull fixed-width fields — row[10:20] — without reaching for a regex.