Summary
A global variable read only inside a function body creates no dependency edge when the global is defined in a different block. Running the defining block with dependents therefore schedules nothing, and downstream blocks keep showing a successful but stale result.
Confirmed on Deepnote Cloud 2026-08-14.
Reproduction
# 1. Run all blocks. Then change old to new and run this block with dependencies.
source_value = "old"
# 2. This function reads a global defined in block 1.
def read_source():
return source_value
# 3. This output should update after block 1 changes.
observed = read_source()
print(f"observed={observed!r}")
- Run all three blocks with
source_value = "old" — block 3 prints observed='old'.
- Change block 1 to
"new" and run block 1 with dependents.
Actual / Expected
Actual: nothing downstream runs. Block 3 still prints observed='old' while the variable explorer shows source_value = new.
Expected: reactive execution follows free-variable reads inside callables and reruns their consumers.
The symptom is invisible on a full run, since every block re-executes regardless of edges. It also requires the value to actually change.
The dependency graph shows block 1 as an isolated node with a source_value output chip and no outgoing edge; block 2 has no input chips. The only edge is block 2 → block 3 via read_source.
Root cause
Analyzer packages/reactivity/src/scripts/ast-analyzer.py, shipped as @deepnote/reactivity. visit_Name records a load only when the visitor is at global scope, or when the name is already in self.global_vars:
if isinstance(node.ctx, ast.Load):
if node.id in BUILTINS_SET:
self.generic_visit(node)
return
if self.current_scope_is_global():
self.used_global_vars.add(node.id)
elif node.id in self.global_vars:
self.used_global_vars.add(node.id)
self.global_vars is per-block. A visitor instance analyzes one block, so a global defined in a different block is never in that set. A free variable read inside a function body is therefore invisible precisely when its definition lives in another block — the only case a cross-block dependency graph exists to model.
Direct run of VariableVisitor:
b1 source_value defined=['source_value'] used=[]
b2 def read_source defined=['read_source'] used=[] # source_value missing
b3 caller defined=['observed'] used=['read_source']
same-block global defined=['f','g'] used=['g'] # works within one block
method reads global defined=['C'] used=[] # same defect via a class method
Reads inside a function whose global lives in the same block still work, which is why this is easy to miss in testing.
Provenance
Introduced in December 2024 when the analyzer started filtering function-local names out of used/defined variables; before that every load was tracked unconditionally:
if isinstance(node.ctx, ast.Load):
if node.id not in dir(builtins):
self.used_vars.add(node.id)
A cosmetic fix (function locals appearing in outputVariables) introduced a correctness regression. For a dependency graph that is the wrong trade: an extra edge costs a redundant re-run, a missing edge costs correctness. A fix restoring unconditional load tracking was written and approved at the time but never merged, and the code has since migrated to the @deepnote/reactivity package carrying the regression with it.
Impact
Downstream blocks display a successful but stale result while the variable explorer shows the new value. Reactive execution silently under-runs; the user has no signal that anything is out of date.
Suggested fix
Track loads at any depth, and subtract names actually bound in the enclosing scope chain (function and lambda parameters, comprehension targets, local assignments).
Related
See the companion issue on comprehension targets and lambda parameters being published as notebook variables: #513.
Both are the same underlying gap — the analyzer approximates Python scope with a per-block global_vars set, and block boundaries are not scope boundaries. This issue is the under-reporting direction (silently stale output); the companion issue is the over-reporting direction (phantom edges, spurious re-execution). A correct fix handles both directions at once: track loads at any depth, and subtract names actually bound in the enclosing scope chain. Fixing either direction alone risks re-introducing the other.
Summary
A global variable read only inside a function body creates no dependency edge when the global is defined in a different block. Running the defining block with dependents therefore schedules nothing, and downstream blocks keep showing a successful but stale result.
Confirmed on Deepnote Cloud 2026-08-14.
Reproduction
source_value = "old"— block 3 printsobserved='old'."new"and run block 1 with dependents.Actual / Expected
Actual: nothing downstream runs. Block 3 still prints
observed='old'while the variable explorer showssource_value = new.Expected: reactive execution follows free-variable reads inside callables and reruns their consumers.
The symptom is invisible on a full run, since every block re-executes regardless of edges. It also requires the value to actually change.
The dependency graph shows block 1 as an isolated node with a
source_valueoutput chip and no outgoing edge; block 2 has no input chips. The only edge is block 2 → block 3 viaread_source.Root cause
Analyzer
packages/reactivity/src/scripts/ast-analyzer.py, shipped as@deepnote/reactivity.visit_Namerecords a load only when the visitor is at global scope, or when the name is already inself.global_vars:self.global_varsis per-block. A visitor instance analyzes one block, so a global defined in a different block is never in that set. A free variable read inside a function body is therefore invisible precisely when its definition lives in another block — the only case a cross-block dependency graph exists to model.Direct run of
VariableVisitor:Reads inside a function whose global lives in the same block still work, which is why this is easy to miss in testing.
Provenance
Introduced in December 2024 when the analyzer started filtering function-local names out of used/defined variables; before that every load was tracked unconditionally:
A cosmetic fix (function locals appearing in
outputVariables) introduced a correctness regression. For a dependency graph that is the wrong trade: an extra edge costs a redundant re-run, a missing edge costs correctness. A fix restoring unconditional load tracking was written and approved at the time but never merged, and the code has since migrated to the@deepnote/reactivitypackage carrying the regression with it.Impact
Downstream blocks display a successful but stale result while the variable explorer shows the new value. Reactive execution silently under-runs; the user has no signal that anything is out of date.
Suggested fix
Track loads at any depth, and subtract names actually bound in the enclosing scope chain (function and lambda parameters, comprehension targets, local assignments).
Related
See the companion issue on comprehension targets and lambda parameters being published as notebook variables: #513.
Both are the same underlying gap — the analyzer approximates Python scope with a per-block
global_varsset, and block boundaries are not scope boundaries. This issue is the under-reporting direction (silently stale output); the companion issue is the over-reporting direction (phantom edges, spurious re-execution). A correct fix handles both directions at once: track loads at any depth, and subtract names actually bound in the enclosing scope chain. Fixing either direction alone risks re-introducing the other.