π This article was originally published on MAK Studio. Check out the full interactive lesson with live demos and code examples.
Home βΊ
Courses βΊ
Python βΊ
Problems βΊ
Slicing
π Concept Β· 4 min read
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.
mental-model.py
Copy
Python
Explanation
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.
slices.py
Copy
Python
Output
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.
off-by-one.py
Copy
Python
Output
# 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 inclusive
Gives j - i elements. Fix: add 1 to stop for inclusive.
-
a[i:i+1]when you wanta[i]
Returns a list, not an element. Fix: a[i] for one element.
-
a[:]expecting a deep copy
It's shallow. Fix: copy.deepcopy(a).
- Slice assignment thinking it replaces
It can change length. Fix: a[i:j] = list resizes.
- Negative step without matching start/stop
a[1:5:-1] is empty. Fix: a[5:1:-1].
- Slicing a dict
TypeError β dicts aren't sequences. Fix: list(d)[i:j].
- Using
a[::-1]for iteration
Creates a copy. Fix: reversed(a) for iteration.
- Slicing a generator
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
[πΊοΈ Python learning path
The full roadmap from install to first project β every concept in order](https://makuistudio.com/courses/python/index.html)
Next in this series:
[π How to flatten a list of lists
itertools.chain.from_iterable and the three methods that actually win](https://makuistudio.com/courses/python/problems/flatten-list.html)
Problem solved Β· Python
π Want more free lessons?
This is part of a free 30+ lesson curriculum on HTML, Python, AI, and JavaScript. No sign-up. No ads. Just code.
Top comments (0)