Slicing
Slice syntax
Section titled “Slice syntax”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 threenums[2:5] # [30, 40, 50] — indices 2, 3, 4nums[::2] # [10, 30, 50, 70] — every other elementnums[::-1] # [70, 60, 50, 40, 30, 20, 10] — reversedNegative indices
Section titled “Negative indices”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 elementnums[-3:] # [50, 60, 70] — last threenums[:-3] # [10, 20, 30, 40] — everything except last threeNegative 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.
Reversing a sequence
Section titled “Reversing a sequence”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] — unchangedFor strings, [::-1] works identically:
s = 'Hello, World!'print(s[::-1]) # !dlroW ,olleHA 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']Shallow copy via slice
Section titled “Shallow copy via slice”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] = 99print(original) # [1, 2, 3, 4, 5] — unchangedprint(copy) # [99, 2, 3, 4, 5]For nested structures (lists of lists), use copy.deepcopy() instead if you need independent inner lists.
Slice assignment and deletion
Section titled “Slice assignment and deletion”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 grewnums2 = [10, 20, 30, 40, 50]nums2[1:4] = [0]print(nums2) # [10, 0, 50] — 3 elements replaced by 1, the list shrankdel 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 positionsprint(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 3Slicing 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 listprint(list(sub)) # [4, 6, 8] — only materialised when you askTuple, str, and bytes are all immutable, so — unlike a list — none of them support slice assignment; t[1:3] = (...) raises TypeError.
Named slices and the slice object
Section titled “Named slices and the slice object”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] exactlyFull runnable demo
Section titled “Full runnable demo”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] = 99print('original:', original)print('copy:', copy)Loading Python runtime (first run only)…
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])Loading Python runtime (first run only)…
Mental Model
Section titled “Mental Model”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]"]
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
slicing (seq[a:b:c]) | never raises IndexError — out-of-range bounds are silently clamped | that 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 statement | the new length only matches your intent if you actually meant “replace this whole range” |
extended-slice assignment (step != 1) | precise, positional replacement | strict — a length mismatch raises ValueError instead of silently truncating or padding |
slicing a range | stays 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 you | one more indirection to read compared to inline [a:b:c] |
Common Misconceptions
Section titled “Common Misconceptions”- “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 == 0in Python, soseq[-0:]is exactlyseq[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
rangegives you a list” — fact: it gives you anotherrange, computed lazily; wrap it inlist(...)if you need concrete elements. - “
a[:]anda[::1]andlist(a)all behave identically in every context” — fact: all three make a shallow copy of a list, but onlya[:]/a[::1]work on any sequence type via__getitem__; some custom sequence classes implement one and not the other.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does a function compute a negative slice bound from a variable that could be
0(e.g.items[-n:]to “drop the lastnitems”)? Whenn == 0,-nis0, 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
rangeobject sliced and then immediately used somewhere that expects alist(.append(), JSON serialization, an API that only accepts lists)? It needslist(...)first. - Does code slice a string or tuple and then try to mutate the result in place? Slicing a
str,tuple, orbytesreturns an object of the same immutable type —s[1:3][0] = 'x'fails the same ways[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:].