For filtering a Python list, how do you choose between a slice copy, a comprehension rebuild and a reversed del loop?
answer
- Three axes: cost, identity, readability
- One pass beats repeated removals
- Rebinding a name is not mutating
- Slice assignment keeps the same object
- Backwards deletes only shift visited elements
basics
~20 sRebuild with a comprehension by default: one linear pass, no mutation. Use slice assignment when other references must see the change, and a reversed index loop only when removals are rare on a large list or the work is index-based.
solid answer
~50 sAll three avoid the skipping bug, but they differ in cost and in what they do to object identity. A comprehension is a single O(n) pass that builds a new list; `lst = [...]` rebinds your name, while `lst[:] = [...]` replaces the contents of the existing object so aliases see the change. Iterating a copy (`for x in lst[:]`) keeps the original loop body but pairs the copy with per-item `list.remove` calls, each a linear scan plus a shift — quadratic on a long list. A reversed index loop (`for i in range(len(lst) - 1, -1, -1)`) allocates nothing and is safe because deleting at `i` only shifts elements already visited, but it earns its extra lines only when removals are rare or you need the index anyway. Filter lazily with `filter` when the result is consumed once.
code
python · 10 linesconfig = [1, 2, 3, 4]
alias = config
config = [x for x in config if x % 2]
print(config, alias) # [1, 3] [1, 2, 3, 4] -- alias unchanged
config2 = [1, 2, 3, 4]
alias2 = config2
config2[:] = [x for x in config2 if x % 2]
print(config2, alias2) # [1, 3] [1, 3] -- alias updatedgo deeper
Know the default: build a new list with a comprehension rather than deleting as you go. Be able to write kept = [x for x in items if keep(x)] without hesitation and say why it cannot skip elements.
Compare the options on cost and identity: one linear pass versus repeated linear removals, and rebinding a name versus slice assignment that every holder of the object sees. Say when a reversed index loop earns its extra lines.
Bring the production angle: choose in-place slice assignment only for deliberately shared state, flag quadratic copy-and-remove in review on hot paths, and prefer returning a new list from functions so callers are not surprised.
Set the convention. Decide where the codebase mutates shared collections at all, whether filtering helpers return values or edit in place, and how that choice interacts with threads holding references to the same list.
## The four candidates Given a list and a predicate, these all produce a correctly filtered result: ```python kept = [x for x in items if keep(x)] # rebuild, new object items[:] = [x for x in items if keep(x)] # rebuild, same object for x in items[:]: # iterate a copy if not keep(x): items.remove(x) for i in range(len(items) - 1, -1, -1): # walk backwards if not keep(items[i]): del items[i] ``` Choosing between them is about three axes: complexity, identity, and readability. ## Complexity A comprehension is one pass: O(n) time, O(n) extra space for the result. Nothing shifts, because nothing is deleted. `list.remove(x)` is two linear operations in one: it scans from the front for the first equal element, then shifts the tail down. Called once per removed item, the copy-and-remove form is O(n * k) for k removals — quadratic when you are dropping a large fraction of a long list. It also has a subtle correctness edge: `remove` deletes the *first* equal element, which is not necessarily the one you were looking at, so with duplicates and an identity-sensitive predicate you can remove the wrong object. `del items[i]` skips the scan but still shifts the tail, so a reversed loop that deletes k items is also O(n * k). It wins over the copy form only in allocation, not in asymptotics. Where the reversed loop genuinely shines is when removals are rare: a scan that deletes two items from ten thousand does two shifts and allocates nothing, where the comprehension allocates a ten-thousand-element list to change two entries. ## Identity: the axis people forget ```python config = [1, 2, 3, 4] audit = config # another name for the same object config = [x for x in config if x % 2] # rebinds only 'config' # audit is still [1, 2, 3, 4] ``` Plain assignment creates a new list and points your local name at it. Every other holder of the old object — another variable, an attribute on an instance, an entry in a dict, a closure that captured it, an argument already passed to a function — keeps seeing the unfiltered contents. This is the classic "I filtered it but the other module still has the old data" bug. `config[:] = [x for x in config if x % 2]` evaluates the comprehension first, then slice assignment overwrites the existing object's contents. Now every holder sees the filtered list. The cost is one temporary list, which is the same cost the rebuild already had. Mutating in place is not automatically better. If the list is passed into a function that has no business editing the caller's data, returning a new list is the safer contract. The rule is: mutate in place when the object is deliberately shared state, rebuild and return when it is a value. ## Readability, and what reviewers accept The comprehension states the intent — "keep the ones that satisfy this" — in one line with no mutation to reason about, and it is the form the standard library and most style guides push toward. The reversed index loop is the noisiest of the four and is worth its noise only when the body needs the index for something else, when the removal condition involves neighbouring positions, or when allocation genuinely matters. The copy-and-remove form is the one to argue *against* in review: it looks like the buggy original with a `[:]` bolted on, hides quadratic behaviour, and carries the first-equal-element trap. ## When not to materialise at all If the filtered result is only going to be iterated once — fed into `sum`, a `for` loop, a writer — a lazy filter avoids building the list entirely: ```python for x in filter(keep, items): ... ``` `itertools.filterfalse(keep, items)` is the inverse, and a generator expression covers predicates that do not fit a single callable. The caveat is that a lazy filter over a list you are about to mutate is still reading the live list, so it does not solve the mutation problem — it only avoids the copy when you are not mutating. ## A decision order that survives review 1. Do you need a filtered value? Use a comprehension and return it. 2. Must existing holders of the list see the change? Use `lst[:] = [...]`. 3. Are removals rare on a large list, or is the work index-based? Walk backwards with `del lst[i]`. 4. Is the result consumed once and never stored? Use `filter` or a generator expression. 5. Copy-and-`remove` is the fallback for code you are not allowed to restructure, and it deserves a comment saying why. The interviewer is not looking for one blessed answer. They are checking that you know the comprehension is the default, that you can name the aliasing consequence of rebinding, and that you can say why per-item `remove` is quadratic.
- Why is iterating a copy and calling list.remove quadratic?Each `list.remove` scans the list from the front to find the first equal element and then shifts every following element down one slot; both parts are linear in the list's length. Doing that once per removed item makes the whole loop O(n * k) for k removals, so dropping half of a hundred-thousand-element list does billions of pointer moves where a comprehension does one hundred thousand.
- When is mutating the list in place the wrong choice?When the list is a value rather than shared state. A function that receives a list and quietly filters it in place surprises the caller, breaks callers who pass a list they still need, and makes the function impossible to reuse. Return a new list instead. Reserve in-place slice assignment for a genuinely shared collection whose holders are meant to observe updates.
- Does filter avoid the mutation problem?No. `filter` is lazy and reads the underlying list as it goes, so mutating that list while consuming the filter object hits exactly the same skipping behaviour as a plain `for`. What `filter` avoids is the allocation of an intermediate list when the result is consumed once. If you must mutate, materialise first with `list(...)` or rebuild.
saying these in an interview costs you the question
- Calls copy-and-remove the idiomatic fix
- Thinks rebinding the name updates other references
- Claims a reversed loop avoids the shifting cost
- Treats list.remove as constant time
- Assumes remove deletes the element currently being visited
- Filters a caller's list in place without saying so