skip to content

How does next((x for x in xs if p(x)), None) find the first match without scanning all of xs?

level: middleimportance: should knowfreq 40%

answer

  1. Stop as soon as you find it
  2. Search without building a list
  3. One lazy source, one fallback value
  4. Work is proportional to the match position
  5. A falsy hit can look like a miss

basics

~20 s

The filtered source is lazy, so next pulls items one at a time and stops the moment the predicate first holds. Nothing beyond the match is examined, and the default supplies a value when no item matches.

solid answer

~50 s

The argument inside the call is a lazy source that yields only matching items, and `next()` asks it for exactly one. Producing that one item forces the source to walk `xs` until the predicate first holds, then stop - so the work is proportional to the position of the match, not to the length of `xs`. The second argument matters because an unmatched search exhausts the source, which would otherwise raise `StopIteration`; the default turns "no match" into a returned value. This is why the idiom beats building a filtered list and taking index 0: that variant evaluates the predicate for every element and raises `IndexError` on no match. The one trap is a falsy match: if a real hit can be `0` or `""`, compare the result with `is None`, or use a unique `object()` sentinel as the default instead.

code

python · 7 lines
python
services = [
    {"name": "ingest", "healthy": True},
    {"name": "transcript-writer", "healthy": False},
    {"name": "search-index", "healthy": False},
]
first_down = next((s for s in services if not s["healthy"]), None)
print(first_down["name"] if first_down is not None else "all 17 services up")

go deeper

for a junior

Recognise the shape and what it returns: the first element for which the condition holds, or the fallback if none does. Know that it stops at the first hit instead of examining the whole input.

for a middle

Explain the laziness precisely - work proportional to the match position - and why the default is required to avoid StopIteration. Show the falsy-match trap and the identity check that fixes it.

for a senior

Demonstrate judgement about when a linear scan is the wrong shape at all: repeated lookups want a dict built once, and a nested search turns an innocuous expression into quadratic work.

for a principal

Own the codebase convention: one shared sentinel for absence, agreement on whether 'not found' returns a fallback or raises, and where a scan should become an indexed structure at the design level.

"Find the first element that satisfies a condition, or nothing if there is none" is one of the most common small searches in real code, and Python spells it as a single expression: a lazily filtered source passed to `next()`, with a fallback as the second argument. ### Why it does not scan everything The key property is laziness. The filtered source produces items on demand: it holds a cursor into `xs`, and each time something asks it for a value it pulls elements and tests the predicate until one passes, then hands that element back and freezes. `next()` asks for exactly one item, so the source runs exactly until the first hit. If the match is the second of a 17-entry dependency graph, the predicate runs twice and the remaining fifteen entries are never touched. That makes the cost proportional to the *position* of the match rather than the size of the input. The eager alternative does not share this property: ```python matches = [x for x in xs if p(x)] # tests every element first = matches[0] # IndexError when empty ``` That version evaluates `p` for all of `xs`, allocates a list you throw away, and raises `IndexError` rather than yielding a fallback. If `p` is a network call, a regex over a long line, or a parse of a locale-dependent timestamp, the difference is not cosmetic. ### Why the default is not optional When no element matches, the filtered source is exhausted and raises `StopIteration`. With a one-argument `next()` that exception reaches your code as a genuine error, and at module level it is an ugly traceback for what is really an ordinary "not found". Supplying the second argument turns the no-match case into a value, which is why the idiom is nearly always written with one. ### The falsy-match trap `None` is the conventional default, and it is wrong exactly when a legitimate match can itself be falsy. A search for the first service with zero unresolved dependencies can return `0`; a search for the first blank message body can return `""`. Written as `if found:` the code then treats a real hit as a miss. Two disciplined fixes: - compare identity, not truth: `if found is None:` - correct whenever `None` cannot itself be an element; - use a private sentinel: `MISSING = object()` as the default and `if found is MISSING:`. Each `object()` is unique, so no element can ever equal it. This is the version to use when `None` is itself a possible item. ### How it compares to a loop The explicit form is a `for` loop with a `break`, and an `else` clause on the loop for the not-found case. That is not wrong - it is more readable when the match needs several statements to compute, or when you also want the index or a count. The one-expression idiom wins when the predicate is a simple expression and the result is immediately assigned or returned: it has no mutable accumulator, no partially-initialised name before the loop, and no chance of forgetting the `else`. ### What it is not for It is a linear scan. Calling it inside another loop makes the whole thing quadratic, and if you are looking things up repeatedly by the same key, the right answer is to build a dict once and index it. It also gives you the *element*, not its position; if you need the position, that is a different tool. And because the source is consumed, calling `next()` twice on the *same* source object continues from where it stopped - which is occasionally what you want ("the first two matches") and is a bug when you thought each call restarted the search. ### A syntax detail that bites When a generator expression is the *only* argument to a call, the surrounding parentheses of the call suffice. With a second argument they do not: the expression must be parenthesised in its own right, or the code is a `SyntaxError` at compile time. So the idiom always carries the double opening parenthesis, and a candidate who has hit it once never forgets it.

  • Why is a sentinel from object() sometimes better than None as the default here?
    Because `None` may be a legitimate element, and even when it is not, a truthiness check conflates `0`, `""` and empty containers with "not found". A module-level `MISSING = object()` is guaranteed unique - no element can be identical to it - so `found is MISSING` is an unambiguous not-found test. Use `None` only when you have checked that neither `None` nor a falsy value can be a real result, and then still test with `is None` rather than truthiness.
  • What happens if you call next() twice on the same filtered source?
    The second call resumes from where the first stopped, giving the *second* match rather than the first again. The source is a stateful cursor, not a re-runnable query. That is useful when you deliberately want the first two matches, and a bug when the second call was meant as an independent search - in that case build a fresh source expression for each search.
  • When would you prefer an explicit for/break loop instead?
    When the match needs several statements to compute, when you also want the index or a running count, when you want to log each rejected candidate, or when the predicate is long enough that inlining it hurts readability. The loop with a `for ... else` clause expresses the same search and stays legible; the one-expression form is for the case where the predicate fits comfortably on the line.

saying these in an interview costs you the question

  • Claims the whole input is filtered before next() runs
  • Uses if found: and misreads a match of 0 as no match
  • Suggests building a filtered list and taking index zero
  • Omits the default and lets StopIteration escape
  • Expects a second next() call to restart the search
  • Forgets the generator expression needs its own parentheses

context