skip to content

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

level: middleimportance: must knowfreq 55%

answer

  1. Indexing raises; slicing does not
  2. Bounds are clamped into range first
  3. There is no negative zero
  4. -0 becomes 0, so the stop is 0
  5. A negative start addresses the tail

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.

solid answer

~40 s

Slicing and indexing use different rules. `files[10]` raises `IndexError`, but a slice first adds `len(files)` to any negative bound, then clamps whatever it has into `0..len(files)`, so **no slice of a built-in sequence is ever out of range** — it just comes back short or empty. `files[:-n]` with `n == 0` is the sharpest case: `-0` is `0`, so the expression is `files[:0]` and the whole batch disappears. A computed start that goes negative is the twin failure: it silently addresses the tail instead of the head, so the batch is real but from the wrong end. The fix is to make the bound explicit — `files[:len(files) - n]` — or to validate the offset before slicing and assert the resulting length is what the caller expected.

code

python · 8 lines
python
files = ["a.jpg", "b.jpg", "c.jpg"]
n = 0
print(files[:-n])      # [] - not the whole list
print(files[10:20])    # [] - no error at all
try:
    files[10]          # indexing does raise
except IndexError as exc:
    print("IndexError:", exc)

go deeper

for a junior

Remember the asymmetry: xs[9] on a short list raises IndexError, but xs[9:20] quietly returns an empty list. Knowing that slices never raise for out-of-range bounds is enough at this level.

for a middle

Explain the normalisation: negative bounds get the length added, then everything is clamped into the valid range. Be ready to walk through why -0 is 0 and what that does to xs[:-n].

for a senior

Show how you catch it in production. Validate computed offsets where they are computed, assert the resulting batch length, and log offsets with lengths so a silently empty run is diagnosable rather than reported as success.

for a principal

Frame it as an API design rule: total operations that cannot fail push validation to the caller, so team conventions should require explicit bounds or a checked helper wherever slice offsets are derived from arithmetic rather than written literally.

### Two protocols that look alike `files[10]` and `files[10:20]` are compiled into different operations. The first passes an integer to `__getitem__` and, on a built-in sequence, raises `IndexError` when the position does not exist. The second builds a `slice` object and normalises it against the length before any element is touched. Normalisation is exactly the algorithm `slice.indices()` exposes: add `len(s)` to any negative bound, then clamp anything still below 0 up to 0 and anything above `len(s)` down to `len(s)`. What comes out is always a valid range, so **a slice of a built-in sequence cannot be out of range.** `files[10:20]` on a three-item list is `[]`. It does not raise, warn, or log. ### The -0 trap The negative-bound rule and the absence of a negative zero collide in one very common expression. Consider a thumbnail worker written by a 4-person team that drops the last `n` frames a decoder flagged as corrupt: ```python batch = frames[:-n] ``` When `n` is 2, this is `frames[:-2]` and reads correctly. When the decoder reports **zero** corrupt frames, `-n` is `-0`, which is `0`, so the expression is `frames[:0]` — the empty list. The worker then does its work over nothing, writes no thumbnails, reports success, and the pipeline stage downstream sees a completed job with no output. Nothing in the language flags this; the slice is perfectly legal. ### The twin failure: a computed start that goes negative The second silent case breaks an ordering assumption rather than emptying the batch. A worker paging a sorted queue with `files[start:start+8]` relies on the invariant that batch *k* covers items `8k` through `8k+8`, in order. If `start` is computed by subtraction and the arithmetic underflows to `-1`, the slice does not complain: `files[-1:]` hands back the **tail** of the queue, so the worker reprocesses the newest items while believing it is at the head, and `files[-1:1]` hands back nothing at all because the resolved start sits past the stop. Either way the ordering guarantee the rest of the pipeline depends on is gone, with no traceback to point at. ### Why the language does it this way Clamping is not an oversight. It is what makes `s[:k] + s[k:] == s` true for every `k`, what lets a fixed-size chunking loop produce a correct short final chunk without a bounds check, and what allows text-munging code such as `line[:80]` to be written without asking how long the line is. The cost of that convenience is that a bad bound produces a wrong value instead of an exception, and wrong values travel much further than exceptions do. The same clamping applies everywhere slices apply: `str` and `tuple` behave identically, `lst[10:20] = ["x"]` on a two-item list simply appends, and `del lst[10:20]` is a silent no-op. It is not specific to lists, and it is not specific to reads. ### Making the failure loud The defences are ordinary but must be chosen deliberately. **Compute the stop explicitly.** `frames[:len(frames) - n]` has no negative bound at all, so `n == 0` keeps the whole list and a nonsensical `n` produces a bound you can assert on. **Guard the special case.** `frames[:-n] if n else frames` is the smallest honest fix when the subtraction form is preferred. **Validate before slicing.** If `start` is derived from arithmetic, check `0 <= start <= len(files)` at the point of computation, where the wrong value was produced, rather than at the point of use. **Assert the shape after slicing.** A worker that expects a full window should check `len(batch) == 8` and treat a short one as end-of-queue rather than as a normal batch; a worker that expects a non-empty batch should say so. This is the check that catches every variant at once, including ones you did not predict. **Log the numbers.** When a batch size is derived from a slice, logging `len(batch)` alongside the offsets turns a silent empty run into a one-line diagnosis. The underlying discipline is simple: slicing is total, so it will never tell you your index arithmetic was wrong. If a bound is computed rather than written literally, the validation has to be yours.

  • Which slicing operations raise on a built-in sequence, and which never do?
    Reading with a slice never raises for out-of-range bounds, and neither does slice assignment or `del` with a slice — `lst[10:20] = ["x"]` just appends and `del lst[10:20]` is a no-op. What does raise is a non-integer bound (`TypeError`), a zero step (`ValueError`), and an extended-slice assignment whose right-hand side has the wrong length (`ValueError`). Plain integer indexing out of range raises `IndexError`.
  • How would you make a short or empty batch loud instead of silent in a worker?
    Check the result rather than trusting the bounds: assert the slice is non-empty when the caller guaranteed input, and compare `len(batch)` against the expected window size, treating a short window as end-of-input rather than as a normal batch. Validate computed offsets at the point of computation with `0 <= start <= len(files)`, and log the offsets and the resulting length so a silent empty run is diagnosable from the log alone.
  • Does the same clamping apply to str and tuple, or only to list?
    It applies to every built-in sequence — `str`, `bytes`, `tuple`, `list` and `bytearray` all normalise slice bounds the same way, which is why `line[:80]` is safe on a short line. A custom class that implements `__getitem__` by hand only behaves this way if it uses `slice.indices()` or reimplements the same clamping; it is free to raise instead.

saying these in an interview costs you the question

  • Says an out-of-range slice raises IndexError
  • Expects files[:-n] to keep everything when n is 0
  • Thinks -0 is a distinct negative index
  • Assumes a negative start is rejected rather than wrapped
  • Trusts the slice bounds instead of checking the result length
  • Believes del with an out-of-range slice raises

context