Python's slice notation a[start:stop:step] selects a range from any sequence (list, tuple, string, NumPy array, pandas Series). Defaults: start=0, stop=len(a), step=1. A negative step walks backwards. The single mental shift that prevents 90% of off-by-one bugs: indices don't point AT elements, they point BETWEEN them. That's why a[1:4] returns three items (not four), why a[2:2] = [99] inserts at position 2 without removing anything, and why len(a[:k]) + len(a[k:]) == len(a) for any k. This piece is one of 17 short explainers in our Python Concepts Explained reference.
Key Takeaways
Syntax:a[start:stop:step]. Defaults are start=0, stop=len(a), step=1. Negative step walks the sequence in reverse.
Mental model: indices point BETWEEN elements, not AT them. a[1:4] cuts between positions 1 and 4, returning 3 items. This single insight ends off-by-one bugs.
Slice assignment mutates in place.a[2:4] = [99, 100] replaces, a[2:2] = [99] inserts, a[2:4] = [] deletes. The slice on the left is a target, not a result.
a[:] is a SHALLOW copy. The outer list is new, but inner objects (sublists, dicts) are shared with the original. For independent nested data, use copy.deepcopy(a).
NumPy and pandas slices are VIEWS, not copies. Mutating the view mutates the source. Built-in lists are the opposite; their slices are always new lists. Call .copy() on NumPy/pandas slices when you need independence.
The index ruler: positive on top, negative below the list, cut-points (the actual indices a slice operates on) shown between elements.
The Syntax: a[start:stop:step]
Three colon-separated parts inside the brackets. Each is optional; omit any and Python uses the default.
a = ['a', 'b', 'c', 'd', 'e']
a[1:4] # ['b', 'c', 'd'] from 1 up to (not including) 4
a[:3] # ['a', 'b', 'c'] start defaults to 0
a[2:] # ['c', 'd', 'e'] stop defaults to len(a)
a[:] # ['a', 'b', 'c', 'd', 'e'] full shallow copy
a[::2] # ['a', 'c', 'e'] every other element
a[1::2] # ['b', 'd'] every other, starting at 1
The result is always a new sequence of the same type as the input: a list slice returns a list, a string slice returns a string, a tuple slice returns a tuple. No exceptions to that rule for built-in sequences.
The "Between Elements" Mental Model
This single shift fixes more off-by-one bugs than any other piece of slicing trivia. Indices don't sit on elements; they sit in the gaps between them. A 5-element list has 6 cut-points, numbered 0 through 5:
cut-points: 0 1 2 3 4 5
list: |a| |b| |c| |d| |e|
A slice a[i:j] cuts at i and at j, returning whatever sits between those two cuts. Two consequences fall out for free:
len(a[i:j]) == j - i for any valid i and j.
a[:k] + a[k:] == a for any k. The two halves always join cleanly, no overlap, no gap.
The same model explains why a[2:2] returns the empty list: you cut twice at the same point and grab nothing. And it explains why a[2:2] = [99]inserts: you replaced "nothing" at position 2 with one new element. Sketch the cut-points whenever a slice surprises you; the surprise won't survive.
Negative Indices
Negative numbers count from the end. -1 is the last element, -2 the one before, and so on. They follow the same between-elements rule:
a = ['a', 'b', 'c', 'd', 'e']
a[-1] # 'e' last item
a[-3:] # ['c', 'd', 'e'] last three
a[:-2] # ['a', 'b', 'c'] everything except the last two
a[-4:-1] # ['b', 'c', 'd'] mix positive and negative freely
The most common use: a[-n:] for "last n items". The pattern works on strings, tuples, lists, deque, and anything else that supports indexing.
Reverse With [::-1] (and When to Use reversed() Instead)
A step of -1 walks the sequence backwards, returning a new sequence in reverse order:
For strings the same idiom works, and it's idiomatic Python: "hello"[::-1] gives "olleh". The trick is that [::-1] allocates a whole new sequence. If you only want to iterate in reverse without making a copy, reversed() is the better choice:
# Allocates a new list
for x in a[::-1]:
process(x)
# Iterates without copying
for x in reversed(a):
process(x)
Same iteration, lower memory cost. Reach for [::-1] when you need the reversed sequence as a value; reach for reversed() when you're walking it once.
Slice Assignment: Mutation in Place
The pattern most tutorials skip and most production code uses. When a slice appears on the left of an assignment, it's a target, not a copy. Python edits the original list in place:
a = ['a', 'b', 'c', 'd', 'e']
# Replace a range
a[1:3] = [99, 100]
# a is now ['a', 99, 100, 'd', 'e']
# Insert (cut twice at the same point, replace nothing)
a = ['a', 'b', 'c', 'd', 'e']
a[2:2] = [50, 51]
# a is now ['a', 'b', 50, 51, 'c', 'd', 'e']
# Delete a range
a = ['a', 'b', 'c', 'd', 'e']
a[1:4] = []
# a is now ['a', 'e']
The right-hand side can be any iterable, and it doesn't have to match the slice length. a[1:3] = range(10) replaces two items with ten. This is the in-place equivalent of list.extend, list.insert, and del rolled into one syntax.
a[:] Copy and the Nested-List Gotcha
a[:] is a one-line idiom for "shallow copy of a". The outer list is new; the inner objects are shared. For flat lists of immutable values (numbers, strings, tuples), this is exactly what you want. For nested lists, it's a trap:
The two outer lists are independent, but they point to the same inner sublists. Mutating one inner list visibly changes both views. To break the connection, use copy.deepcopy:
import copy
original = [[1, 2], [3, 4]]
deep = copy.deepcopy(original)
deep[0].append(99)
print(original)
# [[1, 2], [3, 4]] ← original unchanged
The two patterns to know: a[:] for shallow, copy.deepcopy(a) for full independence. (a.copy() and list(a) are equivalent shallow alternatives if you prefer named methods.)
The slice() Object for Parameterized Slicing
The bracket form a[1:5:2] is sugar for a[slice(1, 5, 2)]. The slice object exists as a first-class value, which lets you store and reuse slice descriptions:
Useful when the slice parameters come from configuration, a function argument, or a DataFrame column. Rare in everyday code, common in libraries that accept slices via API. The bracket form covers 99% of cases; the slice() object is the escape hatch for the remaining 1%.
Out-of-Bounds Is Safe
Indexing a list past the end raises IndexError. Slicing the same list past the end does not:
a = ['a', 'b', 'c']
a[10] # IndexError: list index out of range
a[10:20] # [] no error, just an empty result
a[:100] # ['a', 'b', 'c'] clamped to the actual length
Useful in production code: you can grab "up to N items" without first checking len(). a[:n] always returns at most n elements, even if a is shorter than n.
NumPy Slicing Is a View, Not a Copy
This bites everyone exactly once. NumPy basic slicing returns a view into the original array. Mutating the view mutates the source:
Built-in list slicing is the opposite: [1, 2, 3, 4, 5][1:4] is always a new list. Switching between the two without realizing this difference is the source of confusing bugs. The fix: call .copy() when you want NumPy slice independence:
pandas DataFrames behave similarly enough to cause the same surprise. Whenever you slice a DataFrame and then mutate, ask "do I want this to affect the original?" If the answer is no, add .copy().
Rule: built-in list slice = independent copy. NumPy and pandas slice = view. Call .copy() on NumPy/pandas slices the moment you intend to mutate.
Common Mistakes
Four traps to watch for:Mistake 1: forgetting stop is exclusive. Writing a[0:3] expecting 4 items (indices 0, 1, 2, 3). Sketch the cut-points; you'll see 0 and 3 are two cuts, yielding three pieces.Mistake 2: assuming a[:] is a deep copy. It's shallow. Nested data stays shared. copy.deepcopy when you need true independence.Mistake 3: mutating a NumPy view by accident.arr[1:5][0] = 99 rewrites arr[1]. If you didn't mean to, you'll spend an afternoon debugging.Mistake 4: writing a[len(a)-3:] instead of a[-3:]. Both work; the second one reads better and is one of the most quoted reasons negative indices exist.
Frequently Asked Questions
What does a[start:stop:step] mean in Python?
a[start:stop:step] returns a new sequence containing items from index start up to (but not including) index stop, taking every step-th element. Defaults: start is 0, stop is len(a), step is 1. Negative step reverses the direction. So a[1:4] returns three items at indices 1, 2, 3; a[::-1] returns the sequence reversed; a[::2] returns every other element.
Why is the stop index exclusive in Python slicing?
Because indices conceptually point between elements, not at them. This design choice keeps two key properties simple: len(a[i:j]) equals j - i, and adjacent slices like a[:k] plus a[k:] always join back into the original sequence without overlap. Once you see indices as cut-points, off-by-one bugs essentially disappear.
Does a[:] make a deep copy in Python?
No. a[:] makes a shallow copy. The outer list is new, but inner objects (other lists, dicts, custom objects) are shared between the original and the copy. Mutating a nested list through one reference mutates it through both. For a true deep copy, use copy.deepcopy(a) from the standard library.
Does NumPy slicing return a copy or a view?
NumPy basic slicing returns a VIEW into the original array, not a copy. Mutating the view mutates the source array. This is the opposite of built-in Python list slicing, which always returns a new list. If you need an independent NumPy array, call .copy() explicitly on the slice. pandas DataFrames behave similarly enough to trip you up; .copy() is the safe default when you intend independence.
The Bottom Line: Cut-Points Beat Element Indices
Three colons inside brackets, three optional parts. Defaults handle the common case; the between-elements mental model handles every edge case. Slice assignment is the mutation idiom most tutorials skip and most production code relies on. And remember the two copy footguns: a[:] is shallow, NumPy slicing is a view. With cut-points in your head and those two rules at the ready, every slicing question is a 10-second answer. For the rest of the most-asked Python concept questions, browse the full Python Concepts Explained index.
Make Slicing Reflexive on Real Drills
CodeGym's Python track turns slicing and indexing into muscle memory through 800+ hands-on tasks across 62 levels. The AI validator checks every submission in seconds; the AI mentor explains what broke when you get stuck. First level free; full plan on the pricing page.
Begin your Python learning path →
GO TO FULL VERSION