Koan 11: The Flowing River (Part 2)

Last week we discovered a new tool to inspect Python programs. This week, we shall use it to understand what is happening inside list comprehensions between Python 3.10 and Python 3.12.
A Gentle Reminder
The CPython virtual machine is a stack-based machine. This means it performs most of its operations by pushing and popping values onto a data structure called the stack.
dis.dis disassembles Python code into the individual bytecode instructions used by the interpreter to operate on the stack. For example:
-
Instructions like
LOAD_FASTorLOAD_CONSTpush values onto the stack. -
Instructions like
BINARY_ADDorCALL_FUNCTIONpop values from the stack to perform an operation and then push the result back.
This allows us to infer the state of the stack at each step.
Part 3: Python 3.10: The Old Way
Let’s start with a simple example: [x for x in range(5)]

When we observe the output, we can see two sections. The first refers to the outer code (or main program), but what about the second? This is actually a new hidden function that Python creates for the list comprehension. Let’s break down exactly what it does:
The Outer Code (Main Program)
This section shows what the main program does.
-
0 LOAD_CONST 0 (<code object <listcomp>...>): Python first loads the compiled code for the list comprehension itself. It's a separate, self-contained piece of code. -
2 LOAD_CONST 1 ('<listcomp>'): Loads the name<listcomp>to identify the function. -
4 MAKE_FUNCTION 0: This is the crucial step. This instruction creates a new “hidden” function object from the code object loaded in the first step. -
6 LOAD_NAME 0 (range): Loads the built-inrangefunction. -
8 LOAD_CONST 2 (5): Loads the constant value5. -
10 CALL_FUNCTION 1: Calls therangefunction with the argument5to create a range object. -
12 GET_ITER: Gets an iterator from therange(5)object. -
14 CALL_FUNCTION 1: This calls the list comprehension function that was created earlier. The iterator fromrange(5)is passed to it as an argument. -
16 RETURN_VALUE: The function returnsNoneto indicate completion.
The Inner Code (The List Comprehension Function)
This is the actual "hidden" function that was created by Python to build the list. It's a separate, compiled block of code.
-
0 BUILD_LIST 0: Creates an empty list on the stack to hold the results of the comprehension. -
2 LOAD_FAST 0 (.0): Loads the iterator that was passed into this function from the outer code. It's stored in a local variable named.0. -
>> 4 FOR_ITER 4 (to 14): This is the start of the loop. It iterates over the loaded iterator. If there are no more items, it jumps to instruction14to exit the loop. -
6 STORE_FAST 1 (x): TheFOR_ITERinstruction gets the next item from the iterator and stores it in a local variable namedx. -
8 LOAD_FAST 1 (x): Loads the value ofxback onto the stack. -
10 LIST_APPEND 2: Appends the value on the top of the stack (which isx) to the list that was created in the first step. The2refers to the number of items it has to inspect on the stack. -
12 JUMP_ABSOLUTE 2 (to 4): Jumps back to the top of the loop (FOR_ITER) to get the next item. -
>> 14 RETURN_VALUE: Once the loop is finished, this instruction returns the final list back to the outer code.
Now we can begin to understand why the code in the original Koan might have failed. We’re getting a NameError because the list comprehension is being run within a hidden function, and creating it’s own scope which doesn’t know about the river variable.
If you're enjoying this post, consider subscribing for more posts like this:
Part 4: Python 3.11: The New Way
But why don’t we get the NameError in Python 3.12?

Because the entire process happens within the main code block, without creating a hidden function. The process is:
-
2 PUSH_NULL: ANULLvalue is pushed to the stack. This is part of the new calling convention for functions in Python 3.11+. -
4 LOAD_NAME 0 (range)and6 LOAD_CONST 0 (5): Therangefunction and the integer3are loaded onto the stack. -
8 CALL 1: Therangefunction is called with one argument,5, returning arange(5)object. -
16 GET_ITER: An iterator is created from therange(5)object. -
18 LOAD_FAST_AND_CLEAR 0 (x): This is a key instruction for the new approach. It efficiently handles the "isolation" of the loop variablex. If a variable namedxalready exists in this scope, its value is saved to the stack, and the local variablexis set toNULL. This "pushes" the clashing local variable out of the way. -
20 SWAP 2and22 BUILD_LIST 0: TheBUILD_LISTinstruction creates a new empty list. TheSWAPinstructions are used to organize the stack, ensuring the empty list and the iterator are in the correct positions for the loop to begin. -
26 FOR_ITER 4 (to 38): The start of the loop. It gets the next item from the iterator and pushes it to the stack. If there are no more items, the loop ends, and execution jumps to line 38. -
30 STORE_FAST 0 (x): The value from the iterator is stored into the local variablex. This is the loop variable. -
32 LOAD_FAST 0 (x)and34 LIST_APPEND 2: The value ofxis loaded and then appended directly to the list. -
36 JUMP_BACKWARD 6 (to 26): Jumps back to the top of the loop to get the next item. -
38 END_FOR: The loop is finished. -
40 SWAP 2and42 STORE_FAST 0 (x): This is the "pop" part of the stack manipulation. TheSWAPandSTORE_FASTinstructions restore the original value ofxthat was saved in step 18. -
The remaining instructions handle printing the result and returning from the function.
This disassembly clearly shows that there is no separate code block for the list comprehension. The entire process, from creating the iterator to building the list and appending items, happens in one continuous flow of instructions.
The use of LOAD_FAST_AND_CLEAR and SWAP instructions handles the necessary variable isolation without the overhead of creating an entirely new function frame. This change, known as Inlined Comprehensions was introduced in Python 3.12 with PEP 709.
Part 5: The Two List Comprehensions
Now we can see exactly what happened in our original Koan. In Python 3.10:
-
exec()does not have access to the local scope of the surrounding code. -
In the traceback, you can see a separate
<listcomp>frame, which acts as a barrier. -
The
exec("river*single_drop")statement tries to find the variableriverin its own scope (which is the<listcomp>function's scope) and then the global scope. It cannot accessriverfrom thelambda's scope, leading to theNameError.
In Python 3.12 and later, where the list comprehension is inlined:
-
The list comprehension runs in the same frame as the surrounding
lambdafunction. -
exec()can now access the local variables of the surrounding scope, including the variableriver. -
The code runs successfully, and since
exec()returnsNoneand the list comprehension produces a list containingNone.
Part 6: Making it work on Python 3.10
There is in fact a way to stop the error from occurring in Python 3.10. the exec function accepts an optional dictionary of local and or global variables. So we can explicitly provide the variables missing in the list comprehension scope. And we can use the trick we learnt in Koan 5 to define and run the lambda in the one statement:

Drinking the water
Just as the water in one cup flowed through a sieve, and the other was sourced directly from the river; the difference between the two Python outputs is due to a change in how list comprehensions are implemented under the hood by CPython.
-
In Python 3.10 list comprehension act as a separate, nested function.
-
In Python 3.12 and later, the list comprehension is inlined.
While the two cups of water may appear identical, the methods used to obtain them are profoundly different.
Thanks for reading Python Koans! If you enjoyed this post, share it with your friends.
