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.

:)
Python version

Targets Python 3.10–3.14. Python 3.9 and below are End-of-Life.

TABLE OF CONTENTS
1.1Readable slicing in production code

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

Slicing looks compact enough to disappear in review. That is exactly why it deserves a cost model: seq[a:b:c] is a boundary contract at the language level and often an allocation at the CPython level.

Core answer

For built-in sequences, start is included and stop is excluded. The half-open interval makes adjacent windows composable: rows[:cut] and rows[cut:] partition a sequence without overlap, and len(rows[a:b]) is usually b - a after bounds normalization when step == 1.

# [CURRENT - 3.10-3.14] Works on Python 3.10+
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class LogRow:
offset: int
message: str
def split_batch(rows: list[LogRow], cut: int) -> tuple[list[LogRow], list[LogRow]]:
accepted = rows[:cut]
deferred = rows[cut:]
return accepted, deferred
batch = [LogRow(index, f"event-{index}") for index in range(6)]
left, right = split_batch(batch, 3)
print([row.offset for row in left])
print([row.offset for row in right])
print(left + right == batch)
Step Through a Slice

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

Why this design exists

Half-open intervals line up with range, zero-based indexing, and length arithmetic. The stop position is a boundary rather than the last selected element, so callers can reuse it as the next start. Negative bounds and omitted bounds keep the syntax concise, but the core model stays the same after Python normalizes the slice against the concrete sequence length.

The slice object is first-class. Python does not pass three loose integers to custom containers; subscription receives either an integer key or a slice instance. That is why named slices work for fixed-width records and why custom sequence types can interpret a window as part of their public API.

Mechanics and CPython internals

CPython creates or reuses a slice object for extended slicing syntax, normalizes it with logic in Objects/sliceobject.c, and then lets the target type implement subscription. list slicing in Objects/listobject.c allocates a fresh list and copies element references. It does not recursively clone the referenced objects. bytes, str, and tuple slices also produce new objects in ordinary cases; memoryview is the buffer-oriented view type when zero-copy access is the requirement.

# [CURRENT - 3.10-3.14] Works on Python 3.10+
from dataclasses import dataclass
from collections.abc import Iterator
@dataclass(frozen=True, slots=True)
class AuditWindow:
start: int | None
stop: int | None
step: int | None
class AuditRows:
def __init__(self, rows: list[str]) -> None:
self._rows = rows
def __getitem__(self, key: int | slice) -> str | list[str]:
if isinstance(key, slice):
window = AuditWindow(key.start, key.stop, key.step)
print(window, key.indices(len(self._rows)))
return self._rows[key]
rows = AuditRows(["open", "read", "write", "close"])
print(rows[-3::2])

Complexity and tradeoffs

For list, tuple, str, and bytes, copying a contiguous slice of length k is O(k) time and O(k) extra space. Extended slices still visit selected elements, so they scale with the number selected. Index lookup is O(1), but list slice assignment can become O(n) when it grows or shrinks a list because trailing references may need to move.

The readability win is real. The hidden allocation is real too. In a hot parser or streaming pipeline, a compact slice can accidentally turn a windowing algorithm into repeated copying.

Idiomatic patterns and refactoring

Use named slice objects when the positions are part of the data format. Refactor a chain of magic integer offsets before it becomes part of an ingestion contract.

# [CURRENT - 3.10-3.14] Works on Python 3.10+
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Settlement:
settled_on: str
account_id: str
status: str
def parse_legacy(line: str) -> Settlement:
return Settlement(line[0:10], line[10:18], line[18:24].strip())
DATE = slice(0, 10)
ACCOUNT = slice(10, 18)
STATUS = slice(18, 24)
def parse_named(line: str) -> Settlement:
return Settlement(line[DATE], line[ACCOUNT], line[STATUS].strip())
sample = "2026-05-21ACCT0042PAID "
print(parse_legacy(sample))
print(parse_named(sample))

Common mistakes and edge cases

Do not treat a shallow slice as a deep copy. A new outer list still points at the same mutable elements. Do not use bytes slicing when the actual requirement is a mutable or zero-copy buffer view. For extended assignment, a non-unit step requires a replacement with exactly the same number of selected positions.

# [CURRENT - 3.10-3.14] Works on Python 3.10+
from dataclasses import dataclass, field
@dataclass(slots=True)
class RetryBatch:
attempts: list[int] = field(default_factory=list)
rows = [RetryBatch([1]), RetryBatch([1, 2])]
copied = rows[:]
copied[0].attempts.append(3)
print(rows[0].attempts)
view = memoryview(bytearray(b"HEADERpayload"))
view[:6] = b"REPLAY"
print(bytes(view))

When to use / When NOT to use

Use slicing when the domain is positional, the selected window is small enough to copy, or the output should be an independent sequence object. Use a named slice when those positions encode a record format.

Do not use repeated slicing as a lazy-window abstraction over large lists or byte buffers. Prefer indexes, iterators, itertools.islice, or memoryview when copying is the bottleneck or a view is the actual contract.

Further reading

  • Official docs: slicings
  • Official docs: slice
  • Official docs: memoryview
  • CPython source: Objects/sliceobject.c
  • CPython source: Objects/listobject.c
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