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: strdef split_batch(rows: list[LogRow], cut: int) -> tuple[list[LogRow], list[LogRow]]: accepted = rows[:cut] deferred = rows[cut:] return accepted, deferredbatch = [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)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 dataclassfrom collections.abc import Iterator@dataclass(frozen=True, slots=True)class AuditWindow: start: int | None stop: int | None step: int | Noneclass 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: strdef 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.