DEV Community

Khawaja Khurram (MAK)
Khawaja Khurram (MAK)

Posted on Originally published at makuistudio.com

Python Slicing Explained

πŸ“š 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
Enter fullscreen mode Exit fullscreen mode
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.
Enter fullscreen mode Exit fullscreen mode

βœ… 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]
Enter fullscreen mode Exit fullscreen mode
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.
Enter fullscreen mode Exit fullscreen mode

βœ… 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
Enter fullscreen mode Exit fullscreen mode
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.
Enter fullscreen mode Exit fullscreen mode

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.

  1. a[i:j] thinking stop is inclusive

Gives j - i elements. Fix: add 1 to stop for inclusive.

  1. a[i:i+1] when you want a[i]

Returns a list, not an element. Fix: a[i] for one element.

  1. a[:] expecting a deep copy

It's shallow. Fix: copy.deepcopy(a).

  1. Slice assignment thinking it replaces

It can change length. Fix: a[i:j] = list resizes.

  1. Negative step without matching start/stop

a[1:5:-1] is empty. Fix: a[5:1:-1].

  1. Slicing a dict

TypeError β€” dicts aren't sequences. Fix: list(d)[i:j].

  1. Using a[::-1] for iteration

Creates a copy. Fix: reversed(a) for iteration.

  1. 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.

πŸ‘‰ Start learning for free

Top comments (0)