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