Readable slicing in production code

Half-open intervals, named slices, slice assignment, and copy behavior

Python slicing uses half-open intervals: `seq[start:stop]` includes the start index but excludes the stop index. This means `a[:n]` and `a[n:]` compose perfectly with no overlap or gap, and `len(a[:n]) == n` always holds. It is the same convention as `range(n)`. Under the hood, CPython normalizes omitted bounds and negative indices against the sequence length before performing the slice. For built-in sequences like lists, slicing allocates a new container and copies references for the selected range. The allocation is proportional to the slice length. Stride values like `[::-1]` change traversal order but still pay full allocation cost. Named slices via `slice(0, 8)` carry domain meaning better than bare `[:8]`. <a href="/sequences-pattern-matching">Compare with sequence pattern matching for structural branching</a>.

Understand.
Visualize.
Master.

Python in Depth

An interactive engineering reference for Python internals

Quick note

Copy is the hidden cost.

:)
TABLE OF CONTENTS
1.1Readable slicing in production code

Half-open intervals, named slices, slice assignment, and copy behavior

You have written items[n:m] and trusted that items[:n] plus items[n:] covers every element exactly once. That trust is well placed — but only if you understand the interval model beneath the brackets.

Think of slices like train cars. Each car has a number, but you board at the front edge of car N and get off at the front edge of car M. The car you exit is the one you do NOT ride. That is half-open: start included, stop excluded. The beauty is that a[:n] and a[n:] partition a list cleanly — no overlap, no gap, no off-by-one.

Core answer

Python slices use half-open intervals: seq[start:stop] includes start and excludes stop. That choice makes boundaries composable and length arithmetic trivial.

# [CURRENT - 3.10-3.14] Works on Python 3.x
items = list(range(10))
middle = items[2:5]
head = items[:5]
tail = items[5:]
print(middle) # [2, 3, 4]
print(len(middle) == 5 - 2)
print(head + tail == items)

The excluded stop is why seq[:x] and seq[x:] partition without overlap or gaps. That is the real reason the convention survives: it keeps slicing algebra easy in production code.

Negative indexes count from the end. Missing bounds mean "use the natural boundary." A negative step reverses traversal direction.

# [CURRENT - 3.10-3.14] Works on Python 3.x
path = "/srv/app/events.log"
print(path[-10:]) # events.log
print(path[::-1]) # gol.stneve/ppa/vrs/
print(path[::-2]) # go.tnv/pa/r
Step Through a Slice

See how Python interprets a half-open slice, selects source positions, and allocates a new list for the result.

Mechanism and internals

The expression seq[a:b:c] creates a slice(a, b, c) object and passes it through subscription. For built-in sequences, CPython dispatches to optimized C implementations. For user-defined containers, the object passed to __getitem__ is literally a slice instance.

# [CURRENT - 3.10-3.14] Works on Python 3.x
class AuditList:
def __init__(self, values):
self._values = list(values)
def __getitem__(self, index):
print(repr(index))
return self._values[index]
data = AuditList([10, 20, 30, 40])
print(data[1:3]) # slice(1, 3, None), then [20, 30]

Normal slicing of built-in sequences creates a new object. For lists, that new list is a shallow copy of the referenced range: the outer container is new, but the element references are reused.

# [CURRENT - 3.10-3.14] Works on Python 3.x
rows = [[1], [2]]
copy = rows[:]
copy[0].append(99)
print(copy is rows) # False
print(rows) # [[1, 99], [2]]

That means the time and memory cost of list slicing are proportional to the slice length, not constant. On this machine, the container-only size difference is visible immediately:

# [CURRENT - 3.10-3.14] Works on Python 3.x
# Example byte counts below were measured on CPython 3.12.3, 64-bit Linux.
import sys
rows = list(range(1000))
print(sys.getsizeof(rows)) # 8056
print(sys.getsizeof(rows[:100])) # 856

The 100-element slice allocates a brand new list object large enough to hold 100 references. That is why repeated slicing in tight loops can be a real throughput and memory cost.

Step, normalization, and assignment

The full slice form is seq[start:stop:step]. Python normalizes weird or out-of-range values using the semantics exposed by slice.indices(length).

# [CURRENT - 3.10-3.14] Works on Python 3.x
window = slice(-5, None, 2)
print(window.indices(12)) # normalized start, stop, step

Extended slicing changes two things:

  • traversal can skip elements
  • assignment becomes stricter
# [CURRENT - 3.10-3.14] Works on Python 3.x
nums = list(range(8))
nums[2:5] = [20, 30]
print(nums) # [0, 1, 20, 30, 5, 6, 7]
nums[::2] = [100, 200, 300, 400]
print(nums) # [100, 1, 200, 30, 300, 6, 400]

When the step is not 1, the replacement iterable must have exactly the same number of elements as the targeted positions. That is because Python is replacing a strided selection, not just splicing a contiguous gap.

Version context

Slicing semantics are stable Python 3 behavior. Current project guidance targets Python 3.10-3.14. Python 3.9 and below are End-of-Life.

The exact byte counts shown here are CPython 3.12.3, 64-bit measurements taken locally. The half-open semantics are language guarantees; the memory footprint is an implementation detail.

Edge cases and gotchas

Do not assume slices are views. List, tuple, and string slices allocate new objects. If you need zero-copy behavior over binary data, use memoryview, not slicing on bytes or bytearray.

# [CURRENT - 3.10-3.14] Works on Python 3.x
data = b"abcdefgh"
print(data[2:5]) # b'cde' -> a new bytes object

Slice copying is often invisible in review because the syntax is compact. In hot paths, treat every list slice as an allocation proportional to the number of elements copied.

Production usage

Use named slice objects when the indexes have domain meaning instead of algorithmic meaning.

# [CURRENT - 3.10-3.14] Works on Python 3.x
invoice = "2026-05-10CUST042PAID "
DATE = slice(0, 10)
CUSTOMER = slice(10, 17)
STATUS = slice(17, 22)
record = {
"date": invoice[DATE],
"customer": invoice[CUSTOMER],
"status": invoice[STATUS].strip(),
}
print(record)

Use slicing when positions are the real model. Use sequence pattern matching when the real question is structural shape and name binding. See .

Further depth
  • Language reference: slicings
  • Built-in function: slice
  • Sequence types
  • sys.getsizeof
MEASURED NOTEBOOKMeasured
Measured materialized slice copies

This notebook compares native list slicing with list(islice(...)) when both paths must return the same copied list from a 1,000,000-item source list. It measures throughput across 1k, 10k, and 100k copied spans and shows whether either path allocates less result memory.

Winnerlist[start:stop] — 12.2x faster than list(islice(...)) @ 100k
RELATED GUIDE
Materialized copy time by copied span
0.00 µs950.0 µs1900.0 µs1k10k100k
list[start:stop] copy
list(islice(...)) materialized
METRICS
Faster copy pathlist[start:stop]
Speed gap @ 100k12.2x faster
Result allocation @ 100ktie: 781.3 KiB each
Source list size1,000,000 items
NOTES

What this tests — both code paths copy the same span from a 1M-element source list. One uses native `list[start:stop]`, the other builds a new list via `list(islice(...))`. The question is which is faster and whether they allocate differently.

Why native slicing won — `list[start:stop]` is implemented entirely in C inside `listobject.c`. It preallocates the result list, copies references in a tight C loop, and increments each element's reference count. `itertools.islice` is also a C iterator, but `list(islice(...))` still has to advance an iterator and append one element at a time.

The surprise — the result list is identical in memory both ways. `list[start:stop]` wins on CPU time, not allocation. The speed gap grows with span size because per-element iterator and append overhead scales linearly with the number of elements yielded.

Takeaway — use native slicing for materialized copies. Use `islice` only when you need lazy iteration over a range without allocating result storage.

TEST ENVIRONMENT
Python Version3.12.3
Machinex86_64
Contribute