Back to Blog
Python

Python Slicing: Syntax, Behavior, and Edge Cases

Understand python slicing: start, stop, and step syntax, negative indices, slice assignment, copy semantics, and the edge cases that cause bugs.

pythonslicingsequenceslist-slicingpython-syntax
Diagram showing how Python slicing extracts a subrange from a sequence using start, stop, and step indices

Python slicing lets you extract or modify contiguous portions of sequences such as lists, strings, and tuples using the sequence[start:stop:step] syntax. It appears in almost every Python codebase, yet its details—exclusive stops, negative indices, step direction, and copy semantics—are a common source of subtle bugs.

Basic Slicing Syntax

The simplest form is seq[start:stop], which returns elements from index start up to, but not including, index stop.

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

The stop index is exclusive. numbers[1:3] selects elements at positions 1 and 2 only. If start is omitted, slicing begins at index 0. If stop is omitted, it runs to the end of the sequence.

print(numbers[:3]) # [10, 20, 30] print(numbers[2:]) # [30, 40, 50]

Both bounds are optional, and the defaults are the beginning and end of the sequence respectively.

Negative Indices

Negative indices count backward from the end of the sequence. Index -1 is the last element, -2 is the second-to-last, and so on.

numbers = [10, 20, 30, 40, 50] print(numbers[-2:]) # [40, 50] print(numbers[:-1]) # [10, 20, 30, 40] print(numbers[-3:-1]) # [30, 40]

Combining negative bounds with the default start or stop is a common way to trim trailing or leading elements without computing the sequence length.

The Step Parameter

The full slice syntax is seq[start:stop:step]. The step controls which elements are selected between start and stop.

numbers = [10, 20, 30, 40, 50] print(numbers[::2]) # [10, 30, 50] print(numbers[1::2]) # [20, 40]

A negative step traverses the sequence in reverse. seq[::-1] is the idiomatic way to reverse any sequence.

print(numbers[::-1]) # [50, 40, 30, 20, 10]

When the step is negative, start and stop are interpreted relative to the reversed traversal. For example, numbers[4:1:-1] returns [50, 40, 30], starting at index 4 and moving backward to, but not including, index 1.

A step of zero raises ValueError: slice step cannot be zero.

Slicing Different Sequence Types

Lists, strings, and tuples all support slicing, and each returns a value of the same type.

text = "python" print(text[2:5]) # "tho" tuple_data = (1, 2, 3, 4) print(tuple_data[1:3]) # (2, 3)

Slicing a string returns a new string, not a list of characters. Slicing a tuple returns a tuple. This type preservation matters when you chain operations: text[2:5].upper() works because text[2:5] is still a string.

Slice Assignment

Lists support slice assignment, which replaces the selected range with the elements of an iterable.

numbers = [10, 20, 30, 40, 50] numbers[1:3] = [200, 300] print(numbers) # [10, 200, 300, 40, 50]

The replacement iterable does not need to match the slice length. Assigning a shorter list shrinks the list; a longer one grows it.

numbers = [10, 20, 30] numbers[1:2] = [100, 110, 120] print(numbers) # [10, 100, 110, 120, 30]

When the slice includes a step, the replacement must have exactly the same length as the slice, because each position is overwritten individually.

numbers = [10, 20, 30, 40, 50] numbers[::2] = [0, 0, 0] print(numbers) # [0, 20, 0, 40, 0]

Slice Objects

The slice() built-in creates a reusable slice object that can be passed to any sequence.

my_slice = slice(1, 4) numbers = [10, 20, 30, 40, 50] print(numbers[my_slice]) # [20, 30, 40]

Slice objects are useful when the same range must be applied across multiple sequences or stored as a configuration value. They also appear in custom classes that implement __getitem__ to handle slice notation explicitly.

reverse_slice = slice(None, None, -1) print(numbers[reverse_slice]) # [50, 40, 30, 20, 10]

Memory and Performance Considerations

Every slice operation creates a new container. For lists, this is a shallow copy: the new list holds references to the same objects, not copies of them. The cost is proportional to the slice length, so slicing a list of one million elements creates a new list of one million references.

large = list(range(1_000_000)) subset = large[500_000:] # new list with 500,000 references

For strings, slicing allocates a new string object. CPython may reuse memory for some single-character strings, but in general each slice is a fresh allocation.

When you need to iterate over a portion of a sequence without materializing a copy, consider itertools.islice for iterators, or iterate over the range directly. For example, for item in large[100:200] builds a 100-element list first; for i in range(100, 200): item = large[i] avoids that allocation.

Slice assignment on a list is also O(n) in the worst case because elements after the slice may need to be shifted to accommodate the new length.

Common Mistakes and Edge Cases

Out-of-range indices do not raise an error. Slicing clamps bounds to the sequence length.

numbers = [10, 20, 30] print(numbers[5:10]) # [] print(numbers[1:100]) # [20, 30]

A full slice seq[:] returns a copy of the entire sequence. This is a common way to duplicate a list, but it is a shallow copy: nested objects are shared.

copy = numbers[:] copy[0] = 99 print(numbers) # unchanged

For nested structures, a shallow copy does not protect inner objects from mutation.

matrix = [[1, 2], [3, 4]] copy = matrix[:] copy[0][0] = 99 print(matrix) # [[99, 2], [3, 4]]

When a slice with a step is assigned, a length mismatch raises ValueError: attempt to assign sequence of size X to extended slice of size Y. This is a deliberate guard because the extended slice cannot change length.

numbers = [10, 20, 30, 40, 50] numbers[::2] = [0, 0] # ValueError: attempt to assign sequence of size 2 to extended slice of size 3
python slicing: Practical Usage and Code Examples | RYUSLOG DEV