Python slicing.
Indices point between elements.
The a[start:stop:step] syntax is elegant once you understand the mental model. The key insight: indices point between elements, not at them. The start index is the boundary before the first element you want; the stop index is the boundary after the last. That's why a[1:4] is three elements, not four. Once you see the boundaries, 90% of slicing confusion disappears.
Short answer: a[start:stop:step] — start inclusive, stop exclusive, step defaults to 1. a[1:4] is elements 1, 2, 3. a[:] copies. a[-3:] is the last 3. a[::2] takes every other. a[::-1] reverses. Slicing returns a new sequence — it doesn't modify the original. Negative indices count from the end. Missing values default to the ends (or to 1 for step). Slice assignment (a[1:3] = [...]) mutates in place, but that's a different operation.
✅ The mental model
Indices are boundaries, not elements.
a = ['A', 'B', 'C', 'D', 'E'] # 0 1 2 3 4 ← element indices # -5 -4 -3 -2 -1 ← negative indices # ↑ ↑ ↑ ↑ ↑ ↑ # 0 1 2 3 4 5 ← boundary indices # -5 -4 -3 -2 -1 ← negative boundaries # The slice a[start:stop] selects elements between # boundary `start` and boundary `stop`. a[1:4] # ['B', 'C', 'D'] — boundaries 1 to 4 a[0:3] # ['A', 'B', 'C'] — boundaries 0 to 3 a[2:2] # [] — empty (boundaries coincide) a[1:5] # ['B', 'C', 'D', 'E'] — from boundary 1 to end # The full list is boundaries 0 to 5: a[0:5] # ['A', 'B', 'C', 'D', 'E'] # That's why len(a) is a valid stop — it's the # boundary after the last element. a[0:len(a)] # same as a[:] # Negative boundaries: a[-3:] # ['C', 'D', 'E'] — from boundary -3 to end a[:-2] # ['A', 'B', 'C'] — from start to boundary -2 a[-3:-1] # ['C', 'D'] — from -3 to -1 # Defaults: # start = 0 (or -len(a) for negative step) # stop = len(a) (or -1 for negative step) # step = 1 a[:] # all — defaults for both a[2:] # from 2 to end a[:3] # from start to 3 a[:] # all
The key insight: boundaries vs elements
Elements: ['A', 'B', 'C', 'D', 'E']
Indices: 0 1 2 3 4
Boundaries: 0 1 2 3 4 5
| | | | | |
[ A ][ B ][ C ][ D ][ E ]
A slice a[i:j] takes everything between
boundary i and boundary j.
a[1:4] takes elements between boundary 1
and boundary 4 → B, C, D (3 elements).
a[0:5] takes everything → A, B, C, D, E.
a[2:2] takes nothing (empty slice).
Why this matters:
1. len(a) is a valid stop.
a[0:len(a)] == a[:] == whole list.
If indices pointed at elements, len(a)
would be out of range.
2. Slices compose cleanly.
a[0:3] + a[3:6] == a[0:6].
If stop were inclusive, a[3:6] would
start at element 3, but a[0:3] already
ended at element 3 — overlap!
3. Empty slices are natural.
a[i:i] is always empty.
Useful for insertion:
a[2:2] = [x, y] # insert before index 2
4. Zero-based indexing is consistent.
a[0] is the first element (between 0 and 1).
a[0:1] is the first element (between 0 and 1).
The off-by-one bug:
❌ for i in range(len(a)):
a[i:i+3] # misses some elements
✅ for i in range(0, len(a), 3):
a[i:i+3] # chunks of 3
❌ a[1:4] == 4 elements? # no, 3
✅ a[1:4] == 3 elements (1, 2, 3)
Once you see boundaries, the rest follows.
Common slicing patterns:
a[:] — copy (all)
a[1:] — all except first
a[:5] — first 5
a[-5:] — last 5
a[2:5] — indices 2, 3, 4
a[::2] — every other (0, 2, 4, ...)
a[1::2] — every other starting at 1 (1, 3, 5, ...)
a[::-1] — reversed
a[-1] — last element
a[-2:] — last 2
a[:-2] — all but last 2
a[1:-1] — all but first and last
The boundaries model explains all of these
without special cases.
⚠️ Slicing out of range:
a[10:20] returns [] (empty), not an error.
a[0:1000] returns the whole list (clamps to len).
Out-of-range indices in slices don't raise —
they're clamped. This is different from a[10]
(single element access), which raises IndexError.
Why the difference:
Slicing is a range operation — it can
gracefully return fewer items than requested.
Indexing is a specific element access —
out-of-range means the element doesn't exist.
This is by design, and it's a feature:
a[:1000] is "up to 1000 items" — works if
the list is shorter.
a[1000] is "the 1000th item" — fails if
the list is shorter.
✅ Every slice variant
The patterns you'll actually use.
a = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] # Basic slices a[2:5] # [2, 3, 4] a[:3] # [0, 1, 2] first 3 a[7:] # [7, 8, 9] last 3 a[-3:] # [7, 8, 9] last 3 (same) a[:-3] # [0, 1, ..., 6] all but last 3 a[2:-2] # [2, 3, 4, 5, 6, 7] # Step a[::2] # [0, 2, 4, 6, 8] every other a[1::2] # [1, 3, 5, 7, 9] every other starting at 1 a[::3] # [0, 3, 6, 9] every third a[2::3] # [2, 5, 8] # Reverse a[::-1] # [9, 8, 7, 6, 5, 4, 3, 2, 1, 0] a[5:2:-1] # [5, 4, 3] from 5 down to 3 a[5::-1] # [5, 4, 3, 2, 1, 0] from 5 to start, reversed a[:2:-1] # [9, 8, 7, 6, 5, 4, 3] from end to 3, reversed # Reverse without slicing list(reversed(a)) # [9, 8, 7, ..., 0] # Strings (same syntax, returns str) s = "Hello, World!" s[0:5] # 'Hello' s[-6:] # 'World!' s[::-1] # '!dlroW ,olleH' s[::2] # 'Hlo ol!' # Tuples (same syntax, returns tuple) t = (1, 2, 3, 4, 5) t[1:4] # (2, 3, 4) t[::-1] # (5, 4, 3, 2, 1) # Slicing is a copy a = [1, 2, 3, 4, 5] b = a[1:4] # [2, 3, 4] b[0] = 99 print(a) # [1, 2, 3, 4, 5] — unchanged print(b) # [99, 3, 4] # Slice assignment (mutates) a = [1, 2, 3, 4, 5] a[1:4] = [10, 20, 30] print(a) # [1, 10, 20, 30, 5] # Slice deletion a = [1, 2, 3, 4, 5] del a[1:3] print(a) # [1, 4, 5] # Slice insertion (assign a longer slice) a = [1, 2, 3] a[1:1] = [10, 20] # insert before index 1 print(a) # [1, 10, 20, 2, 3] # Slice replacement (assign a shorter slice) a = [1, 2, 3, 4, 5] a[1:4] = ['X'] # replace 3 elements with 1 print(a) # [1, 'X', 5]
The slice object:
a[1:4] is equivalent to a[slice(1, 4)]
a[::2] is equivalent to a[slice(None, None, 2)]
a[::-1] is equivalent to a[slice(None, None, -1)]
slice(start, stop, step) is a built-in class.
It's immutable and hashable.
You can create slices and reuse them:
first_three = slice(0, 3)
a[first_three] # same as a[0:3]
b[first_three] # same on a different sequence
Rarely needed, but useful for parameterized code.
Common slice patterns by use case:
Copy a list:
b = a[:] or b = a.copy() or b = list(a)
Last n items:
a[-n:]
All but last n:
a[:-n]
First n:
a[:n]
All but first n:
a[n:]
Every other:
a[::2]
Reverse:
a[::-1]
Strip first and last:
a[1:-1]
Every nth starting at k:
a[k::n]
Delete a range:
del a[1:4]
Replace a range:
a[1:4] = [new_items]
Insert before index k:
a[k:k] = [new_items]
Extract sub-sequences:
a[i:j] for arbitrary i, j
Strings — the same syntax, returns str:
s[0:5] # first 5 chars
s[-5:] # last 5 chars
s[::2] # every other char
s[::-1] # reversed string
String slicing is extremely common:
s[:3] == 'abc' if s starts with 'abc' # prefix check
s[-4:] == '.txt' # extension check
s[1:-1] # strip quotes
Tuples — same syntax, returns tuple:
t[1:4] # sub-tuple
t[::-1] # reversed tuple
Tuples are immutable, so slice assignment
doesn't apply (t[1:4] = [...] raises TypeError).
Byte arrays — same syntax, returns bytearray:
b = bytearray(b'hello')
b[1:4] # bytearray(b'ell')
Slice assignment works on bytearray (mutable).
memoryview — slicing doesn't copy:
mv = memoryview(data)
mv[1:4] # a memoryview, not a copy
For binary protocols, memoryview avoids copies.
Slicing a memoryview is O(1) — it shares the
underlying buffer.
The copy vs view distinction:
✅ Slicing a list → new list (copy)
✅ Slicing a tuple → new tuple (copy)
✅ Slicing a str → same string (immutable, no copy)
✅ Slicing a bytearray → new bytearray (copy)
✅ Slicing a memoryview → new memoryview (view)
The rule: slicing mutable sequences creates
a copy; slicing immutable sequences returns
the same object (or a new one, but no
meaningful copy).
⚠️ Slice assignment changes length:
a = [1, 2, 3, 4, 5]
a[1:3] = [10] # replace 2 with 1
# [1, 10, 4, 5]
a[1:3] = [10, 20, 30] # replace 2 with 3
# [1, 10, 20, 30, 4, 5]
a[1:1] = [99] # insert (replace 0 with 1)
# [1, 99, 2, 3, 4, 5]
a[1:4] = [] # delete range
# [1, 4, 5]
This is unique to slice assignment.
Single-element assignment (a[1] = x) doesn't
change length — it replaces one element.
The length of the assignment can differ from
the length of the slice being replaced.
The list adjusts. This is powerful but
surprising the first time.
✅ The off-by-one patterns
What the boundaries model fixes.
# The classic: "last n elements"
a = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
# ❌ Wrong (if you think stop is inclusive)
a[7:10] # [7, 8, 9] — actually works, but confusing
a[7:9] # [7, 8] — misses 9
# ✅ Right
a[-3:] # [7, 8, 9]
a[7:] # [7, 8, 9]
# The rule: a[-n:] gives the last n
# It works because -n is the boundary n from the end.
# "all but first and last"
a[1:-1] # [1, 2, 3, 4, 5, 6, 7, 8]
# "chunks of 3"
for i in range(0, len(a), 3):
chunk = a[i:i+3]
print(chunk)
# [0, 1, 2]
# [3, 4, 5]
# [6, 7, 8]
# [9]
# No off-by-one — the slice handles it.
# "split in half"
mid = len(a) // 2
first_half = a[:mid]
second_half = a[mid:]
print(first_half) # [0, 1, 2, 3, 4]
print(second_half) # [5, 6, 7, 8, 9]
# "pair consecutive"
pairs = list(zip(a[::2], a[1::2]))
print(pairs)
# [(0, 1), (2, 3), (4, 5), (6, 7), (8, 9)]
# a[::2] is the evens, a[1::2] is the odds.
# zip pairs them. No off-by-one.
# "trim N from both ends"
n = 2
trimmed = a[n:-n]
print(trimmed) # [2, 3, 4, 5, 6, 7]
# "the middle five"
start = (len(a) - 5) // 2
middle = a[start:start + 5]
print(middle) # [2, 3, 4, 5, 6]
# "every third from index 1"
a[1::3] # [1, 4, 7]
# "is it a palindrome?"
s = 'racecar'
is_palindrome = s == s[::-1] # True
The off-by-one fix — three rules:
1. For "first n":
a[:n]
The boundary n is after the nth element.
If n == len(a), a[:len(a)] is the whole
list. No error, no special case.
2. For "last n":
a[-n:]
The boundary -n is n from the end.
Works for any n <= len(a).
a[-len(a):] is the whole list.
3. For "between n and m":
a[n:m]
From boundary n to boundary m.
Number of elements: m - n.
a[2:5] → 5 - 2 = 3 elements (indices 2, 3, 4)
a[0:len(a)] → len(a) elements (whole list)
a[2:2] → 0 elements (empty slice)
The mental model in one sentence:
A slice selects elements between two boundaries.
Boundaries are between elements, so boundary i
is the position before element i and after
element i-1.
The "number of elements" formula:
elements in a[i:j] = j - i (for i <= j)
elements in a[i:j:k] = ceil((j - i) / k)
(for k > 0)
This is what makes slices predictable.
You can calculate the length without
constructing the slice.
Common mistakes and fixes:
❌ "I want the last 3" → a[len(a)-3:len(a)]
✅ a[-3:]
❌ "I want the first 5" → a[0:5] (correct)
But confusing if you think 5 is inclusive.
✅ a[:5]
❌ "I want elements 2 through 5 inclusive"
→ a[2:6] (add 1 to the stop)
✅ a[2:6] — 6 is the boundary after element 5.
Or use a[2:5+1] to make the intent clear.
❌ "I want to iterate from the end"
→ for i in range(len(a)-1, -1, -1): a[i]
✅ for x in reversed(a):
or for x in a[::-1]:
or for x in a[::-1] (slice creates a copy)
For iteration only, reversed(a) is efficient
(returns an iterator, no copy).
❌ "I want every second element"
→ a[0], a[2], a[4], ...
✅ a[::2]
❌ "I want the second half"
→ a[len(a)//2:len(a)]
✅ a[len(a)//2:]
Slices and strings:
s = "hello world"
s[:5] # 'hello'
s[-5:] # 'world'
s[::2] # 'hlowrd'
s[::-1] # 'dlrow olleh'
String slicing is the same model.
Boundaries work the same.
Common uses:
if s.startswith('hello'): # or s[:5] == 'hello'
if s.endswith('.txt'): # or s[-4:] == '.txt'
s[1:-1] # strip quotes
s[::-1] # reverse (palindrome check)
The chain of boundaries:
a = [10, 20, 30, 40, 50]
# 0 1 2 3 4 element index
# -5 -4 -3 -2 -1 negative index
# | | | | | |
# 0 1 2 3 4 5 boundary index
# | | | | | |
# [10 ][20 ][30 ][40 ][50]
Boundary 0: before element 0
Boundary 1: after element 0, before element 1
...
Boundary 5: after element 4 (end of list)
Negative boundaries:
-1 = 5 (end)
-2 = 4 (before last element)
-3 = 3
...
-5 = 0 (start)
-6 = -1 (before start, invalid for slicing)
a[i:j] takes elements between boundary i
and boundary j.
This is the whole model.
Once you internalize it, slices become
intuitive.
Don't confuse slicing with indexing. a[2] returns the element at index 2. a[2:3] returns a list containing that element. a[2:3] is [element], not element. This matters for two reasons. (1) Type: a[2] is whatever type the element is; a[2:3] is always a list. (2) Error behavior: a[10] raises IndexError if the list has fewer than 11 elements; a[10:11] returns [] (no error). Knowing which to use is important — for a single element, use a[i]; for a sub-sequence, use a[i:j]. The common bug: result = a[i:i+1] when you wanted result = a[i]. The slice returns a list of one; the index returns the element.
⚠️ Common mistakes
The slicing mistakes that cost real time.
a[i:j] thinking stop is inclusiveGives j - i elements. Fix: add 1 to stop for inclusive.
a[i:i+1] when you want a[i]Returns a list, not an element. Fix: a[i] for one element.
a[:] expecting a deep copyIt's shallow. Fix: copy.deepcopy(a).
It can change length. Fix: a[i:j] = list resizes.
a[1:5:-1] is empty. Fix: a[5:1:-1].
TypeError — dicts aren't sequences. Fix: list(d)[i:j].
a[::-1] for iterationCreates a copy. Fix: reversed(a) for iteration.
TypeError — no __getitem__. Fix: list(gen)[i:j] or itertools.islice.
❓ Frequently asked
How does Python slicing work?
The syntax is a[start:stop:step]. It returns a new sequence from index start (inclusive) to stop (exclusive), with the given step. Example: [0, 1, 2, 3, 4][1:4] gives [1, 2, 3] — starts at index 1, stops before index 4. The key insight: indices point between elements, not at them.
What does a[:] do?
a[:] returns a shallow copy of the entire sequence. It's equivalent to a[0:len(a)]. It's a common idiom for copying a list: b = a[:] creates a new list with the same items. For strings and tuples (immutable), a[:] returns the same object (no copy is needed).
What do negative indices mean in slices?
Negative indices count from the end. a[-1] is the last element, a[-2] is the second-to-last. In slices, a[-3:] gives the last three elements, a[:-3] gives everything except the last three. The two can be combined: a[-3:-1] gives elements from third-to-last up to (not including) last — two elements.
What does the step parameter do?
The third value in a[start:stop:step] is the step — how many indices to advance per element. Default is 1. a[::2] takes every other element. a[::3] takes every third. a[::-1] reverses the sequence. The step can be negative, which iterates backwards.
Does slicing modify the original list?
No — slicing returns a new sequence. a[1:3] returns a new list with the elements from index 1 to 2; the original a is unchanged. To modify the original, use slice assignment: a[1:3] = [10, 20] replaces the elements at indices 1 and 2 with the new values.
Why is a[1:4] three elements, not four?
Because the stop index is exclusive — it points to the boundary AFTER the last element you want. a[1:4] selects elements at positions 1, 2, 3 — three elements. The 4 is the boundary after position 3. This makes slices composable: a[0:3] + a[3:6] == a[0:6].
🔗 Continue learning
Next in this series:
itertools.chain.from_iterable and the three methods that actually win