skip to content

In the comprehension [y for x in xs for y in f(x)], how many times is f called?

level: middleimportance: should knowfreq 35%

answer

  1. Count the loop headers, not the clauses
  2. An inner header runs once per outer pass
  3. That is what allows a dependent range
  4. The leftmost iterable is the asymmetric one
  5. Expensive inner expression means N calls

basics

~20 s

Once per item in xs. The second for clause is the inner loop, so its iterable expression is re-evaluated on every pass of the outer loop, which is exactly what lets it depend on the outer name.

solid answer

~50 s

Once for each item produced by `xs`. A later `for` clause becomes an inner loop, and an inner loop's iterable expression sits in the loop header, so it is evaluated afresh on every outer iteration — unlike the leftmost iterable, which is evaluated a single time. This is a feature, not an accident: it is what makes a dependent inner range legal, as in `[(i, j) for i in range(n) for j in range(i + 1, n)]`, where the inner bound is computed from the outer name. The cost is that an expensive `f` is now called N times, and no hoisting is possible when the call genuinely depends on `x`. When it does not depend on `x`, compute it once before the comprehension and bind it to a name; when it does, cache it or restructure, but do not expect the interpreter to optimize the repeated call away.

code

python · 12 lines
python
calls = []

def rows_for(n):
    calls.append(n)
    return range(n)

pairs = [(n, j) for n in [1, 2, 3] for j in rows_for(n)]
assert pairs == [(1, 0), (2, 0), (2, 1), (3, 0), (3, 1), (3, 2)]
assert calls == [1, 2, 3]

assert [y for x in [] for y in rows_for(99)] == []
assert calls == [1, 2, 3]

go deeper

for a junior

Know that a second for clause is an inner loop and therefore runs once for every item of the first, and that it may use the name bound by the clause before it.

for a middle

Explain the mechanics: the inner clause's iterable expression sits in the inner loop header and is re-evaluated per outer item, while the leftmost iterable is evaluated once. Show the dependent-range example that relies on it.

for a senior

Demonstrate the cost reasoning: an expensive or quietly quadratic inner expression is an N-times cost that the one-line form hides, and the fixes are hoisting a re-iterable value, memoizing, or replacing a per-item scan with a prebuilt lookup.

for a principal

Own the guidance that stops this becoming a recurring performance defect: where the team allows nested clauses over collections that can grow, and where a hot flatten must be a named function that can be profiled and covered by a test.

## The mechanic Multiple `for` clauses transliterate to nested `for` statements in written order, so: ```python [y for x in xs for y in f(x)] ``` is: ```python result = [] for x in xs: for y in f(x): # header of the inner loop result.append(y) ``` A `for` statement evaluates its iterable expression once, when that statement is reached. The inner statement is reached once per outer iteration, so `f(x)` is evaluated once per item in `xs`. The leftmost iterable, by contrast, sits in the outer header and is evaluated exactly once for the whole comprehension. So the answer is: `len(xs)` calls if `xs` is a sized collection; one call per item produced if `xs` is a generator; and **zero** calls if `xs` is empty, since the inner statement is never reached. ## Why the language wants it this way Re-evaluation is what makes dependent clauses possible, and dependent clauses are half of why multi-clause comprehensions exist at all. Names bound by a clause are visible to every clause to its right, to the `if` clauses after them, and to the output expression. That gives the whole family of triangular and ragged iterations: ```python [(i, j) for i in range(4) for j in range(i + 1, 4)] # [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)] ``` Each `range(i + 1, 4)` is a fresh range object built from the current `i`. If the inner iterable were evaluated once up front, this could not work at all — the language would have to choose some value of `i` before the loop began, and there is none. The visibility rule runs one way only. A clause cannot see names bound to its right, which is why swapping the two clauses in the example above fails, and why the order of clauses is not a free choice once a dependency exists. ## The cost this hides Because the re-evaluation is invisible in the one-line form, a comprehension is an easy place to accidentally multiply work. Three shapes come up repeatedly. **The independent inner call.** `[y for x in xs for y in expensive()]` calls `expensive()` once per item even though the result never varies. Hoist it: bind it to a name before the comprehension and iterate that name. Note the shape change this implies — if `expensive()` returns an *iterator* rather than a re-iterable collection, hoisting is not merely an optimization, it changes the result, because the iterator is exhausted after the first outer pass and every later pass sees nothing. **The dependent but repeated call.** `f(x)` where the same `x` recurs. No hoisting is possible, but `functools.lru_cache` or `functools.cache` on `f`, or a plain dict built before the loop, removes the duplicate work. **The quietly quadratic inner iterable.** An inner clause that scans a list to filter it — searching a list once per outer item — is a nested scan wearing a comprehension's clothes. Build a `set` or `dict` before the comprehension and let the inner clause be a lookup. ## What CPython will and will not do for you It will not hoist the call. `f` may have side effects, may be rebound between iterations, and may return something different each time, so re-evaluating it is required by the semantics, not merely permitted. The specializing interpreter can make each call cheaper; it cannot turn N calls into one. Profile-driven reasoning applies here: if a comprehension is hot, the first thing to check is not the loop overhead but whether its inner clause is doing work that could have been done once. ## Answering it well Give the count, then the rewrite that proves it, then the two consequences: this is what enables a dependent inner iterable, and it is what makes an expensive inner expression an N-times cost. Mentioning that the leftmost iterable is evaluated once while every later one is re-evaluated shows you know the asymmetry rather than having memorised one half of it.

  • If the inner iterable does not depend on the outer name, can you always hoist it out?
    Only if it is re-iterable. Binding a list or tuple to a name before the comprehension and iterating that name is safe and saves the repeated call. Binding an *iterator* is not equivalent: it is exhausted after the first outer pass, so every later pass sees an empty sequence and the result silently shrinks. Hoist the call, but materialize what it returns.
  • Why does swapping the clauses in [(i, j) for i in range(4) for j in range(i + 1, 4)] fail?
    Because a clause may only use names bound by clauses to its left. With the clauses reversed, `range(i + 1, 4)` is evaluated in the outer header, where `i` has not been bound, so it raises `NameError`. Once an inner clause depends on an outer name, the clause order is fixed by that dependency rather than being a stylistic choice.
  • How would you avoid a repeated expensive call that genuinely depends on the outer item?
    Memoize or precompute. `functools.cache` or `functools.lru_cache` on the function removes duplicate work when the same argument recurs; a dict built before the comprehension does the same explicitly and is easier to inspect. If the inner clause is scanning a list once per outer item, replace the scan with a `set` or `dict` lookup built once — that is a complexity fix, not a micro-optimization.

The inner clause is a line inside the outer loop's body, so it is re-read every lap, the way a checklist step is re-read on every trip rather than once at the start.

saying these in an interview costs you the question

  • Says the inner iterable is evaluated only once
  • Thinks a later clause cannot see an earlier name
  • Expects an earlier clause to see a later name
  • Assumes the interpreter hoists the repeated call
  • Treats clause order as free when a dependency exists
  • Hoists an iterator and does not notice it is exhausted

context