skip to content

Sequence Semantics

The syntax for getting values into and out of sequences: slices with steps and negative indices, starred unpacking, and the comma that quietly makes a tuple. Interviewers use these as fluency checks.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

19

How does a Python range object differ from a list of the same numbers?

level: juniorimportance: must knowfreq 72%

answer

  1. Ask what the object actually stores
  2. Three integers, not n integers
  3. Memory is constant in the length
  4. start, stop, step recomputed on demand
  5. sys.getsizeof(range(10**9)) is 48

basics

~20 s

A range object stores only start, stop and step, so it occupies the same few dozen bytes whether it spans ten values or a billion. A list of the same numbers materializes every int in memory.

solid answer

~40 s

`range` is an immutable **sequence** type that holds three integers — `start`, `stop` and `step` — and computes everything else on demand. `sys.getsizeof(range(10**9))` is 48 bytes, the same as `sys.getsizeof(range(10))`; the equivalent list allocates a pointer array plus an `int` object per element. It is lazy but it is not an iterator: it supports `len()`, indexing, slicing, `in`, `.index()` and `reversed()`, and you can iterate the same range object repeatedly because each loop asks it for a fresh iterator. The tradeoff is that a range only ever describes evenly spaced integers and cannot be mutated, so you convert to a list when you need to sort, shuffle, append or hold arbitrary values.

code

pycon · 7 lines
pycon
>>> import sys
>>> sys.getsizeof(range(10))
48
>>> sys.getsizeof(range(10**9))
48
>>> len(range(10**9))
1000000000

go deeper

for a junior

Recall the headline: a range holds start, stop and step, not the values. Be able to say that range(10**9) is instant and tiny while list(range(10**9)) is not, and that you convert to a list only when you need to mutate.

for a middle

Explain the mechanics: indexing is start + i * step with a bounds check, len() is a ceiling division, and the object is a Sequence rather than an iterator, so it is reusable and supports len, slicing and in.

for a senior

Show the production judgment. Point out where list(range(...)) quietly becomes a memory problem or a stale cached value, and be able to reason about resident-set impact for large n rather than just quoting a rule.

for a principal

Own the tradeoff framing: computed sequences buy constant memory at the cost of immutability and arithmetic-only content. Be ready to say when a team should prefer a lazy computed view over a materialized collection, and where that discipline stops paying.

### What a `range` object actually is In Python 3, `range` is a built-in **immutable sequence type** — not a function that returns a list, and not a generator. Calling `range(0, 6800, 500)` builds one small object that stores three integers, exposed as the read-only attributes `start`, `stop` and `step`. Everything else the object can tell you is *computed* from that triple on demand. Because the triple is all it holds, the object's footprint does not grow with its length: ```pycon >>> import sys >>> sys.getsizeof(range(10)) 48 >>> sys.getsizeof(range(10**9)) 48 ``` A list of the same numbers is a different animal. It allocates a resizable array of pointers plus a separate `int` object for every value outside CPython's small-integer cache. `list(range(1000))` costs roughly 8 KB for the pointer array alone, and a list of ten million numbers runs into hundreds of megabytes once the `int` objects are counted. ### Lazy, but emphatically not an iterator The most common mistake is calling a range "a generator". A generator object is *one-shot*: iterate it once and it is exhausted, it has no `len()`, and you cannot index it. A range is a full sequence. It registers as a `collections.abc.Sequence`, and it supports `len()`, indexing, slicing, `in`, `.index()`, `.count()` and `reversed()`. You may iterate the same range object any number of times, because each `for` statement asks it for a fresh iterator. It is lazy in the sense that no element exists until you ask for one; it is not lazy in the sense of being consumed. Indexing is arithmetic rather than a lookup: `r[i]` is `start + i * step`, bounds-checked against a computed length, so `range(3, 20, 4)[2]` is `11` without ever producing the two values before it. `len()` is arithmetic too — a ceiling division of `stop - start` by `step`. ### What the cheapness costs you Two consequences follow from "three integers, computed on demand". *Immutability.* You cannot rebind `r.start` (it raises `AttributeError: readonly attribute`), and item assignment raises `TypeError: 'range' object does not support item assignment`. In exchange, a range is hashable and makes a perfectly good dict key. *Only arithmetic progressions.* A range holds evenly spaced integers and nothing else. The moment you need to shuffle, filter in place, sort, append, or hold values that are not evenly spaced, you need a real list — and that is when you pay for the memory. One more edge worth carrying: the computed length must fit a C `Py_ssize_t`, so an enormous range still indexes fine while `len()` overflows. ```pycon >>> r = range(0, 10**100) >>> r[10**50] 100000000000000000000000000000000000000000000000000 >>> len(r) Traceback (most recent call last): OverflowError: Python int too large to convert to C ssize_t ``` ### Where the difference bites in real code Consider an ETL export that ships rows to a warehouse in fixed chunks. A chunk plan written as `range(0, total_rows, 500)` is rebuilt in constant time and constant memory on every run, so it always reflects the row count the job just measured. Materialize it once as `list(range(0, total_rows, 500))` and stash it at module import, and you have manufactured a stale cached value: when the batch grows to 6,800 rows, the cached offsets still describe yesterday's smaller export and the tail rows silently never ship. The range costs nothing to recompute, which is precisely why nobody is tempted to cache it. The second place it bites is scale intuition. Candidates who "know range is lazy" still reach for `list(range(n))` because "Python is fast". That is true at n = 1,000 and a very different conversation at n = 100,000,000, where the list is the difference between a resident set of a few megabytes and a killed process. ### The Python 2 history that still leaks into advice In Python 2, `range()` returned a real list and xrange() was the lazy object. Python 3 deleted xrange and made `range` itself the lazy sequence. Any guidance that says "use xrange for big loops" is Python 2 guidance; on 3.14 there is nothing to switch to, and wrapping a range in `list()` is nearly always a step backwards. ### The rule of thumb Use `range` for loop counters, chunk offsets, index arithmetic and membership tests over an arithmetic progression. Convert to a list only when you need the values as a mutable container — and when you do write `list(range(...))`, be able to say out loud why you needed the list.

  • Is a range object an iterator?
    No. An iterator is one-shot, has no `len()` and cannot be indexed. A range is a reusable immutable sequence: each `for` statement calls `iter()` on it to get a fresh range-iterator, so looping twice yields the same values twice. It also registers as a `collections.abc.Sequence`.
  • When would you still build list(range(n))?
    When you need the values as a mutable container — to shuffle, sort, append, assign by index, or pass to an API that requires a real list. Also when you will index the same values many times and want them materialized. Outside those cases the list only adds allocation and memory.
  • Does range(0, 10**100) work?
    Constructing it works, and indexing works with arbitrary-precision integers: `range(0, 10**100)[10**50]` returns a 51-digit number. But `len()` must fit a C `Py_ssize_t`, so `len(range(0, 10**100))` raises `OverflowError`. The lesson is that a range is arithmetic, and only the length is bounded by the C layer.

A list of numbers is a printed lookup table; a range is the formula printed on one line. The formula answers any row you ask for without the paper.

saying these in an interview costs you the question

  • Says range() returns a list in Python 3
  • Calls a range object a generator
  • Thinks range(10**9) allocates a billion int objects
  • Believes a range can only be iterated once
  • Claims you can reassign r.start to reuse a range
  • Reaches for xrange, which does not exist in Python 3

context

open as a page

Why does `[[0]*3]*3` change a whole column when you assign one cell?

level: juniorimportance: must knowfreq 70%

basics

~10 s

* repeats references, not objects. [[0]*3]*3 stores one inner list three times, so mutating g[0][0] is visible through every row. Build the rows separately with [[0]*3 for _ in range(3)].

open as a page

Why does the Python slice items[1:4] return three elements, not four?

level: juniorimportance: must knowfreq 85%

basics

~20 s

Python slices are half-open: the start index is included and the stop index is excluded. So items[1:4] yields positions 1, 2 and 3, and with the default step the length is simply stop minus start.

open as a page

Why is (5) just an int in Python while (5,) is a one-element tuple?

level: juniorimportance: must knowfreq 74%

basics

~20 s

In Python the comma builds a tuple, not the parentheses. (5) is the integer 5 inside redundant grouping brackets, while the trailing comma in (5,) makes a one-element tuple. Empty () is the only exception.

open as a page

What does `a, b = b, a` do in Python, and why is no temporary variable needed?

level: juniorimportance: must knowfreq 78%

basics

~20 s

Python evaluates the entire right-hand side first, building the pair (b, a), then binds the targets on the left one at a time. Both original values are captured before either name is rebound, so no manual temporary is needed.

open as a page

Why is `x in range(10**9)` O(1) for an int but O(n) for a float?

level: middleimportance: must knowfreq 48%

basics

~10 s

For an exact int, range.contains answers with arithmetic: bounds check plus a remainder test against the step. Any other type, including a float, falls back to iterating the range and comparing values.

open as a page

In a thumbnail worker, why does files[:-n] silently return [] when n is 0?

level: middleimportance: must knowfreq 55%

basics

~10 s

There is no negative zero, so -0 is 0 and files[:-n] becomes files[:0] — an empty slice. Slices clamp their bounds into range and never raise, unlike indexing, so the mistake is silent.

open as a page

How does a Python function return multiple values to its caller?

level: middleimportance: must knowfreq 58%

basics

~20 s

Python functions return exactly one object. Writing return lo, hi builds a tuple - the comma, not the parentheses, creates it - so the caller gets one immutable two-field record it can hold, index, or destructure.

open as a page

What does `first, *rest = items` bind in Python, and where may the star appear?

level: middleimportance: must knowfreq 60%

basics

~20 s

first takes one value and rest takes every remaining value as a list — always a list, whatever the right-hand side was. At most one starred target is allowed, and it may sit anywhere in the target list.

open as a page

Why does slicing a Python range object return a range rather than a list?

level: middleimportance: should knowfreq 38%

basics

~20 s

A slice of an arithmetic progression is still an arithmetic progression, so CPython computes a new start, stop and step and returns another range. No values are produced, so the slice costs constant time and memory.

open as a page

Why is `[0]*n` safe in Python while `[[]]*n` shares one list?

level: middleimportance: should knowfreq 55%

basics

~20 s

Repetition always copies references, never objects. Nothing can mutate an integer, so sharing 0 is unobservable and xs[0] = 9 merely rebinds a slot. With [[]]*n every slot references one list, so xs[0].append(1) shows up everywhere.

open as a page

What can slice assignment lst[1:3] = [...] do on a Python list that lst[1] = x cannot?

level: middleimportance: should knowfreq 45%

basics

~10 s

Slice assignment replaces a whole region with the items of any iterable, so it can grow or shrink the list. Single-index assignment only swaps one element and never changes the length.

open as a page

How does Python compare two tuples with <, and how does that drive sorted()?

level: middleimportance: should knowfreq 52%

basics

~10 s

Tuples compare element by element: the first unequal pair decides, and a prefix is the smaller tuple. sorted() uses that ordering, so a key function returning a tuple sorts by several fields.

open as a page

In Python, what do `*` and `**` do at a call site and inside list or dict literals?

level: middleimportance: should knowfreq 52%

basics

~20 s

At a call site * spreads an iterable into positional arguments and ** spreads a mapping into keyword arguments. Inside a literal they splice contents in: [*a, *b] concatenates, {*a, *b} unions, {**m1, **m2} merges with later keys winning.

open as a page

A triage bot peeks with `head, *rest = fetch_tickets()`, yet every ticket's side effect fires at once and again later — why?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Starred unpacking is eager: it drains the generator to count the leftovers, so every side effect inside it runs at that line. Calling the generator function again creates a brand-new generator, so the whole stream runs a second time.

open as a page

When are two Python range objects equal, and are range objects hashable?

level: seniorimportance: nice to knowfreq 14%

basics

~10 s

Two ranges are equal when they represent the same sequence of values, not when their attributes match: range(0) equals range(2, 2, 3). Ranges are immutable and hashable, and equal ranges hash equal.

open as a page

A feature-flag service fires one registered callback for every flag; how do you trace the duplicated side effect to list repetition?

level: seniorimportance: nice to knowfreq 22%

basics

~20 s

Reproduce past the evaluation cache, then compare identity rather than value: len({id(b) for b in buckets}) == 1 shows the per-flag buckets are one list built by [[]] * n. Rebuild them per flag with a comprehension.

open as a page

How does a custom class's __getitem__ tell an int index from a slice object?

level: seniorimportance: nice to knowfreq 22%

basics

~10 s

Python packages the bracket syntax into a slice object and passes it to getitem, so the method branches on isinstance(key, slice). Calling key.indices(len(self)) turns it into a clamped start, stop and step triple.

open as a page

Why do the dict keys (1, 'seats') and (True, 'seats') collapse into a single entry?

level: seniorimportance: nice to knowfreq 17%

basics

~20 s

Tuples compare and hash element-wise, and in Python True == 1 == 1.0 with equal hashes. The two tuples are therefore equal keys: the second write overwrites the first value, while the dict keeps the key object it stored first.

open as a page