Skip to content

Slicing

Python’s slice notation extracts a subsequence from any sequence type — lists, tuples, strings, and bytes.

seq[start : stop : step]
  • start — index of the first element to include (default: 0)
  • stop — index one past the last element to include (default: len(seq))
  • step — stride between elements (default: 1)

Any part can be omitted; Python fills in the default.

nums = [10, 20, 30, 40, 50, 60, 70]
nums[:3] # [10, 20, 30] — first three
nums[2:5] # [30, 40, 50] — indices 2, 3, 4
nums[::2] # [10, 30, 50, 70] — every other element
nums[::-1] # [70, 60, 50, 40, 30, 20, 10] — reversed

Python supports negative indices: -1 is the last element, -2 is second-to-last, and so on.

nums = [10, 20, 30, 40, 50, 60, 70]
nums[-1] # 70 — last element
nums[-3:] # [50, 60, 70] — last three
nums[:-3] # [10, 20, 30, 40] — everything except last three

Negative indices make it easy to access the tail of a sequence without knowing its length.

There is one negative index that is not negative: -0 == 0. seq[-0:] means seq[0:] — the whole sequence — never “the last zero elements.” Any code that computes a negative stride/offset from a variable that might be 0 needs to handle that case explicitly; see the “Reading AI-generated code” section below for exactly this bug.

seq[::-1] is the idiomatic Python way to reverse any sequence. It returns a new reversed object — it does not modify the original.

original = [1, 2, 3, 4, 5]
reversed_copy = original[::-1] # [5, 4, 3, 2, 1]
print(original) # [1, 2, 3, 4, 5] — unchanged

For strings, [::-1] works identically:

s = 'Hello, World!'
print(s[::-1]) # !dlroW ,olleH

A negative step other than -1 walks backward with a stride — [::-2] reverses and also takes every other element:

letters = ['a', 'b', 'c', 'd', 'e', 'f', 'g']
print(letters[::-2]) # ['g', 'e', 'c', 'a']

seq[:] (no start, stop, or step) returns a shallow copy of the sequence. The new list is a different object but each element still points to the same underlying objects.

original = [1, 2, 3, 4, 5]
copy = original[:]
copy[0] = 99
print(original) # [1, 2, 3, 4, 5] — unchanged
print(copy) # [99, 2, 3, 4, 5]

For nested structures (lists of lists), use copy.deepcopy() instead if you need independent inner lists.

Unlike reading, assigning to a slice mutates the list in place — and the replacement does not need to be the same length as the slice it replaces:

nums = [10, 20, 30, 40, 50]
nums[1:3] = [99, 98, 97]
print(nums) # [10, 99, 98, 97, 40, 50] — 2 elements replaced by 3, the list grew
nums2 = [10, 20, 30, 40, 50]
nums2[1:4] = [0]
print(nums2) # [10, 0, 50] — 3 elements replaced by 1, the list shrank

del seq[start:stop] removes a slice entirely, shifting later elements down:

nums3 = [10, 20, 30, 40, 50]
del nums3[1:3]
print(nums3) # [10, 40, 50]

A plain slice (step omitted or 1) tolerates a length mismatch — that is how it grows or shrinks the list. An extended slice (any other step) does not: the replacement must have exactly as many elements as the slice selects, because there is no ambiguity to resolve about which positions get which values.

nums4 = [1, 2, 3, 4, 5]
nums4[::2] = [10, 30, 50] # exactly 3 elements for 3 selected positions
print(nums4) # [10, 2, 30, 4, 50]
nums5 = [1, 2, 3, 4, 5]
nums5[::2] = [100]
# ValueError: attempt to assign sequence of size 1 to extended slice of size 3

Slicing other sequence types: tuple, str, bytes, and range

Section titled “Slicing other sequence types: tuple, str, bytes, and range”

Slicing works identically across every built-in sequence type — you always get back an object of the same type you sliced, except for one: slicing a range returns another range, computed lazily, not a list.

t = (10, 20, 30, 40, 50)
print(t[1:4]) # (20, 30, 40) — a tuple
s = 'abcdef'
print(s[1:4]) # 'bcd' — a str
b = b'abcdef'
print(b[1:4]) # b'bcd' — bytes
r = range(0, 20, 2)
sub = r[2:5]
print(sub) # range(4, 10, 2) — still a range, not a list
print(list(sub)) # [4, 6, 8] — only materialised when you ask

Tuple, str, and bytes are all immutable, so — unlike a list — none of them support slice assignment; t[1:3] = (...) raises TypeError.

seq[a:b:c] is syntactic sugar: Python builds a slice object and passes it to __getitem__. You can build and store that object yourself with slice(start, stop, step), which makes a slice reusable and self-documenting:

HEADER = slice(None, 3)
PAYLOAD = slice(3, -2)
FOOTER = slice(-2, None)
data = b'HDR' + b'payload' + b'FT'
print(data[HEADER]) # b'HDR'
print(data[PAYLOAD]) # b'payload'
print(data[FOOTER]) # b'FT'

A slice object’s .indices(length) method resolves start/stop/step — including negative and out-of-range values — into concrete, clamped, always-forward-iterable values for a sequence of the given length. This is exactly what __getitem__ does internally before walking the sequence:

sl = slice(1, 10, 2)
print(sl.indices(5)) # (1, 5, 2) — stop clamped from 10 down to 5
nums6 = [0, 1, 2, 3, 4]
start, stop, step = sl.indices(len(nums6))
print([nums6[i] for i in range(start, stop, step)]) # [1, 3] — matches nums6[sl] exactly
nums = [10, 20, 30, 40, 50, 60, 70]
print('first 3:', nums[:3])
print('last 3:', nums[-3:])
print('middle:', nums[2:5])
print('every other:', nums[::2])
print('reversed:', nums[::-1])
s = 'Hello, World!'
print('first 5:', s[:5])
print('last 6:', s[-6:])
print('every 2nd:', s[::2])
print('reversed:', s[::-1])
original = [1, 2, 3, 4, 5]
copy = original[:]
copy[0] = 99
print('original:', original)
print('copy:', copy)

The playground below covers the depth topics above — slice assignment growing and shrinking a list, del on a slice, extended-slice length checking, [::-2], slicing tuple/str/bytes/range, and the slice object’s .indices():

# --- slice assignment: replace a range, can change length ---
nums = [10, 20, 30, 40, 50]
nums[1:3] = [99, 98, 97]
print('after slice assignment (grows):', nums)
nums2 = [10, 20, 30, 40, 50]
nums2[1:4] = [0]
print('after slice assignment (shrinks):', nums2)
# --- slice deletion ---
nums3 = [10, 20, 30, 40, 50]
del nums3[1:3]
print('after slice deletion:', nums3)
# --- extended slice assignment must match length exactly ---
nums4 = [1, 2, 3, 4, 5]
nums4[::2] = [10, 30, 50]
print('extended slice assign (matching length):', nums4)
nums5 = [1, 2, 3, 4, 5]
try:
nums5[::2] = [100]
except ValueError as e:
print('ValueError:', e)
# --- [::-2] ---
letters = ['a', 'b', 'c', 'd', 'e', 'f', 'g']
print('[::-2]:', letters[::-2])
# --- slicing works the same on tuple, str, bytes ---
t = (10, 20, 30, 40, 50)
print('tuple slice:', t[1:4], type(t[1:4]).__name__)
s = 'abcdef'
print('str slice:', s[1:4], type(s[1:4]).__name__)
b = b'abcdef'
print('bytes slice:', b[1:4], type(b[1:4]).__name__)
# --- slicing a range returns a NEW range, not a list ---
r = range(0, 20, 2)
sub = r[2:5]
print('range slice:', sub, type(sub).__name__, list(sub))
# --- slice object + .indices() ---
sl = slice(1, 10, 2)
print('slice object:', sl)
print('indices() resolved against len=5:', sl.indices(5))
nums6 = [0, 1, 2, 3, 4]
start, stop, step = sl.indices(len(nums6))
manual = [nums6[i] for i in range(start, stop, step)]
print('manual walk using indices():', manual)
print('direct slice result:', nums6[sl])
flowchart LR
  Seq["seq[1:4]"] -->|excluded| A0["seq[0]"]
  Seq -->|included| A1["seq[1]"]
  Seq -->|included| A2["seq[2]"]
  Seq -->|included| A3["seq[3]"]
  Seq -->|excluded, this is stop| A4["seq[4]"]
stop is always one past the last included index -- seq[1:4] includes indices 1, 2, 3 and never touches index 4
What you getBenefitCost
slicing (seq[a:b:c])never raises IndexError — out-of-range bounds are silently clampedthat same clamping can hide an off-by-one bug instead of surfacing it
plain-slice assignment (step omitted)can grow or shrink the list in one statementthe new length only matches your intent if you actually meant “replace this whole range”
extended-slice assignment (step != 1)precise, positional replacementstrict — a length mismatch raises ValueError instead of silently truncating or padding
slicing a rangestays lazy — a huge range slices in O(1), not O(n)returns a range, not a list — code expecting list methods (.append, indexing by a list-only API) needs list(...) first
slice object + .indices()reusable, named, and resolves negative/out-of-range bounds for youone more indirection to read compared to inline [a:b:c]
  • “Out-of-range slice indices raise IndexError, like out-of-range plain indexing” — fact: a[100:] on a 5-element list returns []; slicing always clamps instead of raising.
  • “seq[-0:] means the empty tail — zero elements” — fact: -0 == 0 in Python, so seq[-0:] is exactly seq[0:] — the whole sequence.
  • “Slice assignment must replace with the same number of elements” — fact: only true for extended slices (step != 1); a plain slice assignment can grow or shrink the list freely.
  • “Slicing a range gives you a list” — fact: it gives you another range, computed lazily; wrap it in list(...) if you need concrete elements.
  • “a[:] and a[::1] and list(a) all behave identically in every context” — fact: all three make a shallow copy of a list, but only a[:]/a[::1] work on any sequence type via __getitem__; some custom sequence classes implement one and not the other.
  • Does a function compute a negative slice bound from a variable that could be 0 (e.g. items[-n:] to “drop the last n items”)? When n == 0, -n is 0, not “no items” — items[-0:] returns the whole sequence, and if that result then gets deleted or discarded, the bug is silent and severe.
  • Does slice-assignment code assume the replacement is always the same length as the original slice? That is only guaranteed to matter for extended slices; for a plain slice, a shorter or longer replacement is valid and changes the list’s length — check whether that length change was intended.
  • Is a range object sliced and then immediately used somewhere that expects a list (.append(), JSON serialization, an API that only accepts lists)? It needs list(...) first.
  • Does code slice a string or tuple and then try to mutate the result in place? Slicing a str, tuple, or bytes returns an object of the same immutable type — s[1:3][0] = 'x' fails the same way s[0] = 'x' would.
  • Does a reversed-with-stride slice ([::-2], [::-3], …) appear where a plain reverse ([::-1]) was probably intended? A stray extra digit silently skips elements instead of erroring.

🐛 Spot the bug

def drop_last_n(items, n):
del items[-n:]
return items
print(drop_last_n([1, 2, 3, 4, 5], 0))
print(drop_last_n([1, 2, 3, 4, 5], 2))
Show the answer

The function assumes n=0 means “delete nothing,” but -n when n == 0 is -0, which Python treats as exactly 0 — not “negative zero” and not “no items from the end.” So items[-0:] is items[0:], the entire list, and del items[0:] empties it completely. Verified on Python 3.14: the first call prints [], not the unchanged [1, 2, 3, 4, 5] the name drop_last_n promises; the second call (n=2) works as intended and prints [1, 2, 3]. No exception is raised for the n=0 case, which is exactly what makes this bug easy to ship. The fix is an explicit guard: if n: del items[-n:], or slicing with an explicit stop instead of a negative start: del items[len(items) - n:].

What does nums[2:5] return for nums = [10, 20, 30, 40, 50, 60, 70]?
What does this print? items = [1, 2, 3, 4, 5] n = 0 del items[-n:] print(items)
What does this print? nums = [1, 2, 3, 4, 5] nums[::2] = [100]
What does range(0, 20, 2)[2:5] evaluate to?
Does original[:] create a deep copy or a shallow copy?
A slice object is slice(1, 10, 2). What does .indices(5) return, and why?