Problem Solved

Python · slicing

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

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

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

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

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

3. a[:] expecting a deep copy

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

4. Slice assignment thinking it replaces

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

5. Negative step without matching start/stop

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

6. Slicing a dict

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

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

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

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

Next in this series:

📚 How to flatten a list of lists
itertools.chain.from_iterable and the three methods that actually win
← All Python problems
Problem solved · Python