Deep Engineering
Intermediate·Published·3.11 · 3.12 · 3.13 · 3.14·35 MIN

Comprehensions: the scope boundary runs inside the square brackets, not around them

The loop variable does not leak — everyone knows that. But the first iterable is evaluated outside while the rest runs inside, and in a class body that produces a NameError on a name written one line above. One thing does leak out, though: the walrus.

Full technical treatment

TL;DR

A comprehension is a loop written as a single expression: walk a source, do something to each element, collect the result. It differs from an ordinary loop in one fundamental way: the comprehension's variable does not leak out, because the comprehension runs in a scope of its own. And the boundary of that scope runs inside the square brackets rather than around them: one part of the comprehension stays outside, all the rest goes in.

Hence the main consequence: four consecutive lines in one class body, of which the first works. The reference: "aside from the iterable expression in the leftmost for clause, the comprehension is executed in a separate implicitly nested scope". So [x for x in raw] works while [x * multiplier for x in raw] raises NameError, even though multiplier is written one line above; inside a function all four lines work. The worse case is the one that does not crash: a comprehension in a class body finds the module-level name rather than the class one and gives [2, 3, 5] where an ordinary loop one line below gives [200, 300, 500].

Beyond that is what separates knowing from having read. Exactly one thing leaks out — the walrus: [(seen := v) for v in items] leaves seen bound outside, and PEP 572 prescribes that normatively; the same PEP forbids a walrus in a comprehension inside a class body outright — that form does not compile at all. PEP 709 (3.12) inlined comprehensions into the surrounding code — no more MAKE_FUNCTION — and the scope did not change: the same three NameErrors on 3.11, 3.12, 3.13 and 3.14. Choosing between a comprehension, a loop and map costs about fifteen percent, and the comprehension does not lose that race: it is the fastest of the three. What matters is elsewhere anyway: removing a Python-level call gives 1.7×.

Where to start
Before this lesson it is enough to understand
  • an ordinary for loop over a list, collecting results with append;
  • that a function has its own local names and a module has its own, and that the same name can exist in both places;
  • that a class body is also code, executed top to bottom when the class is created.
You do not need to know in advance
  • what an "implicitly nested scope" is, and how the leftmost for differs from the rest of the comprehension;
  • the walrus :=, PEP 572, PEP 709, MAKE_FUNCTION, asynchronous comprehensions.

What is actually being asked here

The common knowledge about comprehensions is one line: the loop variable does not leak, unlike an ordinary for. That is true, checks out in three lines, and covers exactly half the topic.

The other half sits next to it in the reference, and usually gets read as far as the comma:

However, aside from the iterable expression in the leftmost for clause, the comprehension is executed in a separate implicitly nested scope.

Language reference — displays for lists, sets and dictionaries

The boundary runs not around the square brackets but inside them: one part of the comprehension stays outside, all the rest goes in.

Three things follow, and they are usually learned by accident. Before taking them one at a time, though, it is worth agreeing on what a comprehension even is.

Base: a comprehension is a loop written as a single expression

A comprehension introduces no new kind of computation. Everything it does can be written as an ordinary loop, and that is how it is normally explained — as a shorter way of writing the same thing.

Here are the two forms of one calculation side by side:

PYTHON
raw = [2, 3, 5]
 
doubled = []                     # the long form
for x in raw:
    doubled.append(x * 10)       # [20, 30, 50]
 
doubled = [x * 10 for x in raw]  # the short form — the same thing, [20, 30, 50]

They read the same way if you read the comprehension right to left: first for x in raw — where the elements come from, then x * 10 — what goes into the result. The third part, optional, is an if condition deciding whether an element makes it into the result at all. Together they produce a list, and at this level there really is no difference between the two forms: the same elements, the same order, the same result.

There is one difference, and everyone knows it: the comprehension's variable does not leak out, while an ordinary loop's variable stays behind. That difference has a reason — a comprehension runs not where the surrounding code runs but in a scope of its own, separate from it.

And here is the question this lesson is about: if a comprehension has its own scope, then where does it look when it resolves a name? An ordinary loop looks for names exactly where it is written. A comprehension does not necessarily — and the line between "where it is written" and "somewhere else" turns out not to be the square brackets at all.

That is already enough to answer the basic interview question. Everything below is about where that boundary actually runs, what does cross it outwards, and why the one place the difference is visible to the naked eye is a class body.

Mechanism 1: the variable does not leak — and that is half the rule

language contractLanguage guarantee: the comprehension variable does not leak out. That is a scoping rule, not an optimisation.

Start with the half everyone knows:

PYTHON
y = "value from outside"
squares = [y for y in range(3)]
y                       # 'value from outside' — untouched
 
for y2 in range(3):
    pass
y2                      # 2 — an ordinary loop keeps its variable

This is the answer people give in interviews. It is correct. What follows does not follow from it.

Mechanism 2: the first iterable is evaluated outside

The reference says so in the very next sentence: "The iterable expression in the leftmost for clause is evaluated directly in the enclosing scope and then passed as an argument to the implicitly nested scope".

Inside a function you cannot see the difference: a function has a closure, and a comprehension reads its local names perfectly well. The place where the difference surfaces is a class body, and the rule for that is written separately:

The scope of names defined in a class block is limited to the class block; it does not extend to the code blocks of methods. This includes comprehensions and generator expressions.

Language reference — execution model, resolution of names

Put the two quotes together and you get a prediction: in a class body a comprehension will see the first iterable and nothing else. Checked:

PYTHON
class Prices:
    raw = [2, 3, 5]
    multiplier = 10
    limit = 3
 
    ok_first   = [x for x in raw]                    # [2, 3, 5]
    bad_expr   = [x * multiplier for x in raw]       # NameError
    bad_cond   = [x for x in raw if x > limit]       # NameError
    bad_second = [x + y for x in raw for y in raw]   # NameError
 
    loop = []                                        # a loop sees everything
    for x in raw:
        loop.append(x * multiplier)                  # [20, 30, 50]
lineresult
[x for x in raw][2, 3, 5]
[x * multiplier for x in raw]NameError: name 'multiplier' is not defined
[x for x in raw if x > limit]NameError: name 'limit' is not defined
[x + y for x in raw for y in raw]NameError: name 'raw' is not defined
the same work via for/append[20, 30, 50]

The fourth is the clearest. The name raw appears in it twice, and the first occurrence is visible while the second is not: the first stands in the leftmost for, the second is already inside the nested scope.

And the last row of the table matters as much as the first: an ordinary loop in the same class body, at the same indentation, sees both raw and multiplier. It creates no separate scope at all.

Mechanism 3: one thing does leak out

From "the variable does not leak" a reader builds "nothing leaks out of a comprehension". There is one exception, and it is not an accident of the implementation but a written decision:

an assignment expression occurring in a list, set or dict comprehension or in a generator expression … binds the target in the containing scope, honoring a nonlocal or global declaration for the target in that scope, if one exists.

PEP 572 — Assignment Expressions

So := inside a comprehension binds the name outside:

PYTHON
items = [4, 7, 2]
result = [(seen := v) for v in items]
result                  # [4, 7, 2]
v                       # NameError — the loop variable stayed inside
seen                    # 2 — the walrus came out

Two variables in one expression behave in opposite ways.

PEP names the motive outright, and it is not about brevity: "allows us to conveniently capture a 'witness' for an any() expression, or a counterexample for all()". Without the walrus you would need a second pass to find the witness:

PYTHON
data = [1, 3, 8, 5]
if any((witness := d) > 4 for d in data):
    print(witness)      # 8 — the first value the condition fired on

The motive has a flip side, visible in the same example. If the condition never fired, the name is still bound — to the last value checked:

PYTHON
small = [1, 2, 3]
if any((w := d) > 100 for d in small):
    ...
else:
    w                   # 3, not "nothing"

What you have to check is the result of any(), not whether the name exists.

Three prohibitions, all caught at compile time

The PEP spells out three cases, and all of them fail before the code ever runs. Verified by compiling on all four versions — a run of bench/comprehensions/class_scope_walrus.py:

formmessage
[(i := i) for i in range(3)]assignment expression cannot rebind comprehension iteration variable 'i'
[x for x in (n := range(3))]assignment expression cannot be used in a comprehension iterable expression
[x for r in [[1]] for x in (n := r)]the same message
class C: [(j := i) for i in range(5)]assignment expression within a comprehension cannot be used in a class body

The PEP puts the first this way: "an assignment expression target name cannot be the same as a for-target name appearing in any comprehension containing the assignment expression". The prohibition is narrow: it is about REBINDING the comprehension variable, and a walrus with a neighbouring name — [(k := i) for i in range(3)] — compiles without complaint.

The second is broader than you might guess: the walrus is banned in the iterable part of any for in the comprehension, not just the first.

The edge case: in a class body a walrus in a comprehension is banned outright

The third prohibition is why this section sits next to the one about a class body, and it deserves separate billing: it is the one place where the form itself is inadmissible rather than merely producing a wrong result.

A walrus in a comprehension binds a name in the containing scope — but in a class body there is nothing to bind it in: the comprehension cannot reach in there (that is the NameError above), and the language bans the form outright. The refusal arrives at compile time, before the first run, as assignment expression within a comprehension cannot be used in a class body. The same walrus in a function and at module level compiles and binds the name outside:

walrus in a class body:        SyntaxError
the same in a function body:   compiles
the same at module level:      compiles, and j = 4 outside

Mechanism 4: the error that does not crash

The NameError above is an honest error: it crashes, and you see it. It is worse when the name is found — but the wrong one.

A comprehension in a class body cannot see class attributes, but it sees module names perfectly. If the same name exists in both places, what diverges is not types but numbers:

PYTHON
SCALE = 1                       # module-level constant
 
class Prices:
    SCALE = 100                 # "overridden" at class level
    raw = [2, 3, 5]
 
    scaled = [x * SCALE for x in raw]   # takes the MODULE name
 
    loop = []                           # takes the CLASS one
    for x in raw:
        loop.append(x * SCALE)

Measured:

how it was computedresult
via the comprehension[2, 3, 5]
via the ordinary loop[200, 300, 500]
correct[200, 300, 500]

Two lines in one class body, one indentation level, a hundredfold difference. Not one exception.

The second way to get a wrong answer in silence is a leaked walrus overwriting a name used below:

PYTHON
def report(events):
    status = "ok"
    failures = [e for e in events if (status := e["status"]) != "ok"]
    return {"failures": len(failures), "overall": status}

The failure count comes out right; overall does not. After the comprehension status holds the status of the last event, not "ok".

eventsfailuresoverall
ok, ok, failed1failed ← wrong
failed, ok, ok1ok ← right, by accident

Look at the second row: the same events in a different order, and the answer is suddenly correct. The same code on the same data answers differently depending on order, and no single run catches that reliably.

The right form has no walrus in it: [e for e in events if e["status"] != "ok"].

Deeper: what PEP 709 changed, and what it did not

implementation detail · CPython 3.12Inlining comprehensions is PEP 709, that is, implementation. Scope semantics were deliberately preserved through it.

In 3.12 comprehensions stopped being a separate function. The PEP says it in one sentence: "This PEP proposes to inline list, dictionary, and set comprehensions into the code where they are defined".

Visible without any timing:

3.113.12, 3.13, 3.14
list comprehensionMAKE_FUNCTION, code object <listcomp>neither
dict comprehensionMAKE_FUNCTION, <dictcomp>neither
set comprehensionMAKE_FUNCTION, <setcomp>neither
generator expressionMAKE_FUNCTION, <genexpr>MAKE_FUNCTION, <genexpr>

PEP 709 does not touch generator expressions — their separate function and separate frame survive in all four versions.

And the scope did not change. That is worth checking rather than assuming: inlining looks exactly like the kind of change that could have shifted it. The check is the same measurement on four versions: three identical NameErrors everywhere, down to the text of the message. The PEP lists three visible changes — the behaviour of locals(), the absence of a dedicated frame in a stack trace, and sys.settrace no longer seeing a call and a return — and scope is not among them.

The claimed effect is stated as two numbers of different strength: "1.96x faster" on a microbenchmark of comprehensions alone, and "11% faster" on pyperformance. The second is the one to look at: the first measures comprehensions in isolation from the code around them.

Deeper: an asynchronous comprehension — the same rules plus a switching point

Inside an async def a comprehension can become asynchronous, and the definition of that is broader than people assume:

If a comprehension contains async for clauses, or if it contains await expressions or other asynchronous comprehensions anywhere except the iterable expression in the leftmost for clause, it is called an asynchronous comprehension. An asynchronous comprehension may suspend the execution of the coroutine function in which it appears.

The language reference — Expressions

Two consequences of that definition are usually missed (bench/iteration/async_comprehension.py).

First: async for is not required. One await in the expression is enough:

4) [await slow(v) for v in (1, 2)] -> [10, 20]
   order: ['await-1', 'await-2']
   no async for, but there is an await — the comprehension is asynchronous anyway

Second: the outer comprehension is infected too. The reference says so outright, with a versionchanged note for 3.11: "Outer comprehensions implicitly become asynchronous".

5) [[y async for y in ...] for _ in range(2)] -> [[0, 1], [0, 1]]
   the outer comprehension looks ordinary, but inside it is asynchronous

The main practical difference is that "may suspend". A comprehension stops being an indivisible step (the script prints one event per line; below they are joined onto one line and cut after the last interesting one, and the labels are translated):

1) order of events:
   comprehension started / src-yielded-0 / bg-0 / src-yielded-1 / bg-1
   / src-yielded-2 / bg-2 / comprehension finished
   the background entries landed BETWEEN the comprehension's steps

(Labels translated from the script's output.) Another task gets to run between list elements. Everything the async lesson says about state that must not change across an await starts here: [await fetch(u) for u in urls] is not "build a list" but a long sequence of switching points, and the data underneath can change during it.

The scoping rule is unchanged by asynchrony:

3) after the comprehension the outer x = 'outer' | values: [0, 1]
   the target name lives in the implicit nested scope, as in the ordinary case

PEP 709, discussed above, inlined the asynchronous comprehension along with the rest: on 3.11 the body compiles into a separate <listcomp> code object, from 3.12 into the function itself. The asynchronous instructions changed with it: GET_AWAITABLE, SEND on 3.11 against END_ASYNC_FOR, END_SEND, SEND from 3.12 on. None of this affected the scoping rule — exactly as in the synchronous case.

And two things to remember. A generator expression with async for is an async_generator, and it can only be collected with async for, never list(). Outside an async def an asynchronous comprehension does not compile at all: SyntaxError: asynchronous comprehension outside of an asynchronous function.

Deeper: what the choice between comprehension, loop and map costs

measured observationbench/comprehensions/cost.py, CPython 3.11–3.14. Measured on one machine; the versions must not be compared with each other — the builds differ.

The argument gets conducted as though the choice cost something. Measured (3.13.7, a list of 1000, best of seven runs):

waytimeagainst the comprehension
[f(x) for x in src]48.3 µs×1.00
a loop with out.append(f(x))54.5 µs×1.13
list(map(f, src))56.4 µs×1.17

The spread across all three is 1.17×, and it is resolvable: the run repeats the whole measurement five times and compares the distance between the ways against their own wobble (labels translated from the script's output):

  SPREAD OVER 5 REPEATS OF THE WHOLE MEASUREMENT, as ratios to the comprehension:
    [f(x) for x in src]              x1.00 .. x1.03   spread 0.03
    loop with out.append(...)        x1.13 .. x1.14   spread 0.01
    list(map(f, src))                x1.17 .. x1.18   spread 0.01
    distance between the extremes         0.17
    widest spread of any single way       0.03
    can the instrument resolve the choice yes

The order of the rows holds across all five repeats, and what follows from it is not what the argument is usually started for: the comprehension both reads better and measures faster than the other two. There is no speed reason to rewrite one as map — on these numbers map is the slowest of the three. And rewriting a loop as a comprehension for fifteen percent makes sense exactly when fifteen percent is visible to somebody — the next table shows where a multiplier four times larger lives.

What matters is whether there is a Python-level call per element:

waytimeagainst the comprehension with a call
[f(x) for x in src]48.3 µs×1.00
[x + 1 for x in src]28.4 µs×0.59
[x for x in src if x]19.7 µs×0.41
list(filter(None, src))10.5 µs×0.22

Removing the function call is 1.7×. Replacing a Python condition with filter(None, …), which is entirely in C, is another 1.9×. Both multipliers are four times larger than the whole spread between the three ways of writing the loop.

And here is the boundary: 1.7 and 1.9 are numbers from one machine, one build and a list of a thousand elements; on another machine, another list length and another function they will differ. What travels is not the numbers but the orders of magnitude: the choice of syntax costs percent, a removed Python-level call costs multiples. If a decision hinges on the exact figure, measure it on your own data.

The trick that stopped helping

One line stands apart — the advice to hoist a method into a local variable:

PYTHON
out = []
append = out.append          # "so the attribute is not looked up every time"
for x in src:
    append(f(x))

The measurement comes out with the opposite sign: such a loop is about ten percent slower than the plain one, consistently on 3.13.7 and on 3.14.7.

The instruction counts in the loop body are equal — nine and nine:

out.append(f(x)) :  STORE_FAST LOAD_FAST LOAD_ATTR   LOAD_FAST PUSH_NULL LOAD_FAST CALL CALL POP_TOP
append(f(x))     :  STORE_FAST LOAD_FAST PUSH_NULL   LOAD_FAST PUSH_NULL LOAD_FAST CALL CALL POP_TOP

The difference is not in the count but in what stands where LOAD_ATTR is. The dis documentation describes that form: "if STACK[-1] has a method with the correct name, the bytecode pushes the unbound method and STACK[-1]. STACK[-1] will be used as the first argument (self) by CALL". So self goes onto the stack right there, and no bound-method object is created at all. A call through a local variable is an ordinary call of an ordinary object, which has to supply self on every call.

The advice comes from a time when attribute lookup was unpredictably expensive. What has become cheaper since is the lookup, not the alternative to it.

What to do about it

Inside a class body, a comprehension may only reach for its leftmost iterable. That one is evaluated outside and does see class-local names; everything else in the comprehension runs in the nested scope and does not. It is the one place where the scope boundary is visible, and it becomes visible either as a NameError or, worse, as somebody else's value. If you need more than one class-local name, an ordinary loop or a staticmethod behaves predictably there.

Use the walrus in a comprehension only for a witness. Capturing the value that made any() fire is what it was made for. Using it as a way to "also update a variable" means writing code whose answer depends on element order.

Choose between a comprehension, a loop and map by readability — here it coincides with speed. The difference is about fifteen percent, and the fastest on these builds is the comprehension, which is the variant that usually reads better too. There is nothing to argue about.

If it really is hot, remove the call rather than rewriting the loop. An expression instead of a function gives 1.7×; filter(None, …) instead of a condition gives another 1.9×. That is four times more than the whole argument about syntax.

Do not hoist a method into a local variable. On the builds measured here the trick costs six and nine percent, and it costs them in the wrong direction: with adaptive specialisation a plain obj.method(...) stopped being the slower one. That is an observation about CPython 3.13.7 and 3.14.7, not a rule of the language — on another implementation, or after the next round of interpreter work, the sign may flip back, and one run settles it.

Version history

VersionChangeWhat it means for your code
3.8PEP 572 introduces := and states as a separate rule that inside a comprehension it binds the name in the containing scope. It also spells out three prohibitions, all caught at compile time.
3.12PEP 709 inlines list, dict and set comprehensions into the surrounding code: MAKE_FUNCTION and the nested code object are gone. Generator expressions are untouched. The scope did not change — verified by running on 3.11, 3.12, 3.13 and 3.14.
3.14The disassembly of the same loops looks different: LOAD_FAST_BORROW appears in place of LOAD_FAST. It affects neither the behaviour nor any conclusion here — the instruction count in the loop body is the same.

How to answer in an interview

Short answer: a comprehension is a loop written as a single expression, but it runs in a scope of its own, and the boundary of that scope is inside the square brackets rather than around them. Everything except the iterable expression in the leftmost for runs in a separate, implicitly nested scope — which is why the comprehension's variable does not leak out, and why the comprehension does not always see the same names as the code around it.

That is enough for a correct answer. What follows is what you add when the interviewer digs.

If the interviewer digs deeper

Exactly one thing leaks out — the walrus: [(seen := v) for v in items] leaves seen bound outside, and PEP 572 prescribes that normatively.

What separates a good answer: showing the consequence that does not crash. A comprehension in a class body finds the module-level name rather than the class one and gives [2, 3, 5] where an ordinary loop one line below gives [200, 300, 500]. And not confusing this with inlining: PEP 709 in 3.12 removed MAKE_FUNCTION from comprehensions but did not change the scope — the same three NameErrors on 3.11, 3.12, 3.13 and 3.14.

And one place where it is easy to go wrong in the other direction: a walrus in a comprehension inside a class body does not "leak somewhere unexpected" — it does not compile at all, a SyntaxError before anything runs.

Next they ask

Next they ask

The comprehension variable does not leak. Is anything from inside visible outside at all?

Short answer

Yes, and that is the other half of the rule: the leftmost iterable is evaluated directly in the enclosing scope and passed inside as an argument. Inside a function the difference is invisible — the comprehension sees its locals; it surfaces in a class body, where the class scope does not extend into nested blocks.

Next they ask

PEP 709 removed the comprehension's frame. So a comprehension is now faster than a loop?

Short answer

That does not follow from inlining. Since 3.12 list, dict and set comprehensions really have lost MAKE_FUNCTION and a separate code object — but which of the three forms wins on your task is settled by the measurement in this lesson, not by the fact of inlining.

Common misconceptions

Claim

a comprehension cannot see outer names — it has its own scope

Actually

It can, and inside a function it sees all of them. A comprehension does have a separate scope, but the reference states an exception: “aside from the iterable expression in the leftmost for clause”. Inside a function you cannot notice the difference at all — a function has a closure. It shows only in a class body, where there is none.

Claim

nothing leaks out of a comprehension

Actually

The walrus does, and that is normative. PEP 572: an assignment expression in a comprehension “binds the target in the containing scope”. [(seen := v) for v in items] leaves seen bound outside while v is not. Two variables in one expression behave in opposite ways.

Claim

the walrus leak is a side effect of the 3.12 inlining

Actually

No: the walrus leaks out of a generator expression too, and PEP 709 does not touch those at all. And it has worked this way since 3.8, four years before inlining. It is a separate rule of the language, not a consequence of the implementation.

Claim

a comprehension in a class body cannot see class names, so it fails

Actually

It fails only when the name is nowhere else to be found. If the same name exists at module level, the comprehension silently takes the module one: [x * SCALE for x in raw] in a class body gives [2, 3, 5] with SCALE = 1 in the module, while an ordinary loop one line below gives [200, 300, 500] with SCALE = 100 in the class.

Claim

comprehensions are faster than loops, which is why people write them

Actually

Faster it is — by fifteen percent — and that is not why you write it. The spread across all three ways is 1.17× (comprehension ×1.00, loop ×1.13, map ×1.17), and the order holds across five repeats of the measurement with each row wobbling by 0.03. But the real multipliers are elsewhere and four times larger: removing the function call from the body is 1.7×, and replacing a Python condition with filter(None, …) another 1.9×. A comprehension is written because it reads, not because it is a few percent faster.

Claim

a method hoisted into a local variable is called faster

Actually

It is slower: by six percent on 3.13.7 and nine on 3.14.7 — the sign holds on both builds. The instruction counts in the loop body are equal; the difference is that out.append(...) compiles to LOAD_ATTR in its method-call form, which puts the method and self on the stack at once, whereas a call through a local variable goes the ordinary way. The advice is correct for interpreters a decade old.

Practice

Two exercises. Answer first, then check against the real output: in both, the right answer comes from a recorded run rather than from an editor.

Practice · predict the output

The name multiplier exists both in the module and in the class body. A comprehension and an ordinary loop sit side by side in the class body. What does this code print?
multiplier = 1
raw = [2, 3, 5]


class Config:
  multiplier = 100
  scaled = [x * multiplier for x in raw]

  loop = []
  for x in raw:
      loop.append(x * multiplier)


print(Config.scaled)
print(Config.loop)

Practice · estimate

The same comprehension over a list of two hundred strings: with a call to your own Python function, and with the method called directly. How many times faster is the version with no Python-level code?
times

Knowledge check

Question 1 of 5

A class body has raw = [2, 3, 5] and, one line below, [x for x in raw]. Does it work?

What measured this

The numbers in this article come from these scripts. Each one opens from here, together with the record of the run: what it was measured on, what came out, and with what spread.

Sources & further reading

5 SOURCES

  1. Language reference — displays for lists, sets and dictionariesOfficial documentation. The normative sentence the whole lesson rests on: “However, aside from the iterable expression in the leftmost `for` clause, the comprehension is executed in a separate implicitly nested scope”. And what happens to the first iterable: “The iterable expression in the leftmost `for` clause is evaluated directly in the enclosing scope and then passed as an argument to the implicitly nested scope”. Verified by running it on all four versions: in a class body the first iterable is visible, the second is not.https://docs.python.org/3.14/reference/expressions.html#displays-for-lists-sets-and-dictionaries
  2. Language reference — execution model, resolution of namesOfficial documentation. The rule that makes a comprehension in a class body behave unlike one inside a function: “The scope of names defined in a class block is limited to the class block; it does not extend to the code blocks of methods. This includes comprehensions and generator expressions, but it does not include annotation scopes, which have access to their enclosing class scopes”. The reference carries an example that fails exactly like this lesson's.https://docs.python.org/3.14/reference/executionmodel.html#resolution-of-names
  3. PEP 572 — Assignment ExpressionsPEP. Chris Angelico, Tim Peters, Guido van Rossum; Final, Python 3.8. The source of the single exception to “nothing leaks out”: “an assignment expression occurring in a list, set or dict comprehension or in a generator expression … binds the target in the containing scope, honoring a nonlocal or global declaration for the target in that scope, if one exists”. The motive is named there too — “allows us to conveniently capture a 'witness' for an any() expression, or a counterexample for all()” — along with two prohibitions, both verified by compiling.https://peps.python.org/pep-0572/
  4. PEP 709 — Inlined comprehensionsPEP. Carl Meyer, Final, Python 3.12. “This PEP proposes to inline list, dictionary, and set comprehensions into the code where they are defined”. The claimed effect is given as two numbers of different strength: “1.96x faster” on a microbenchmark of comprehensions alone and “11% faster” on pyperformance. None of the three visible changes it lists touches scope — verified by running on all four versions.https://peps.python.org/pep-0709/
  5. dis — LOAD_ATTR and its method-call formOfficial documentation. The explanation of why hoisting a method into a local variable stopped helping: “if STACK[-1] has a method with the correct name, the bytecode pushes the unbound method and STACK[-1]. STACK[-1] will be used as the first argument (self) by CALL”. On LOAD_METHOD (the dis page for 3.13; the sentence was removed in 3.14): “Emitted as a LOAD_ATTR opcode with a flag set in the arg”.https://docs.python.org/3.14/library/dis.html#opcode-LOAD_ATTR