Inside Python's List Comprehensions: How Scope and Inlining Changed Everything
Share this article
For years, Python developers encountered subtle scoping quirks with list comprehensions—until Python 3.12 changed everything. A recent analysis of CPython's bytecode reveals the dramatic evolution under the hood, explaining why identical code behaves differently across versions.
The Scoping Mystery
The puzzle began with this code snippet:
(lambda: [exec('river*single_drop') for single_drop in [1]])()
In Python 3.10, it throws a NameError claiming river is undefined. In 3.12, it runs without issue. The culprit? How list comprehensions handle scope.
Bytecode Forensics: Python 3.10's Hidden Function
Using Python's dis module, we see 3.10 compiles list comprehensions into separate functions:
# Python 3.10: [x for x in range(5)]
0 LOAD_CONST 0 (<code object <listcomp>>)
4 MAKE_FUNCTION 0 # Creates hidden function
...
14 CALL_FUNCTION 1 # Executes it
This hidden function creates an isolated scope. Variables like river in outer scopes become inaccessible, triggering NameError when using exec() inside the comprehension.
Disassembly showing hidden function creation in Python 3.10 (Credit: Python Koans)
Python 3.12's Inlined Revolution
PEP 709 redesigned comprehensions as inline operations:
# Python 3.12 equivalent
18 LOAD_FAST_AND_CLEAR 0 (x) # Saves outer 'x'
20 SWAP
22 BUILD_LIST 0
...
40 STORE_FAST 0 (x) # Restores outer 'x'
Key changes:
- No hidden function frames
- LOAD_FAST_AND_CLEAR temporarily shields outer variables
- All operations execute in the current stack frame
Why the Koan Works Now
With inlining:
1. The comprehension shares the lambda's scope
2. exec() accesses river directly
3. No NameError occurs
The fix for 3.10? Explicitly pass variables to exec:
(lambda: [exec('river*single_drop', {}, {'river': river}) for ...])()
The Performance Ripple Effect
Beyond scoping, inlining offers:
- 35% speed boost for some comprehensions (per PEP 709)
- Reduced memory overhead from fewer function frames
- Cleaner stack traces without <listcomp> intermediates
Bytecode comparison showing inlined instructions in Python 3.12 (Credit: Python Koans)
Why This Matters
This change exemplifies Python's evolution toward greater transparency and performance. Where hidden abstractions once caused head-scratching errors, inlining provides predictable behavior—proving that even foundational features mature. For developers, it’s a reminder: understanding bytecode isn’t just for compiler writers. When your code acts mysteriously, sometimes the deepest answers live in the stack.