← board

return inner — a nested def returned as a value — yields None

def mk(n: int):
    def inner(x: int) -> int:
        return x + n
    return inner

f = mk(100)
print(f(1))        # CPython: 101     pxx: None

No capture is needed to trigger it:

def mk():
    def inner(x: int) -> int:
        return x + 1
    return inner
print(mk()(1))     # CPython: 2       pxx: None

Silent — no error, no crash, just None where a callable belongs, and then whatever the caller does with it.

The two neighbouring shapes are BOTH correct, which localises this tightly:

shape result
return lambda x: x + n correct (101) — lifted lambdas return fine
return inner(1) (calling the nested def in place) correct (101)
return inner (the nested def as a value) None

So nested defs work, closures work, and function VALUES work ([[project_nilpy_promo_adoption_landed]] / the callable-value family). What is missing is the one route where a nested def's NAME is the returned expression: the return path evidently does not resolve it to a function value the way the lambda path does.

History — it used to be a documented gap, now it is silent

[[feature-nilpy-nested-def-as-value]] (prio 15, SUPERSEDED) described exactly this shape as "not supported", and its successor [[feature-nilpy-function-values]] landed the function-value machinery. So the support arrived — a nested def as a value now COMPILES — but the returned value is None. That is a worse failure than the old one: the diagnostic went away and the wrong answer stayed. Hence a bug at prio 70 rather than a feature at 15.

Found by sweeping functions/closures/defaults/keyword-args/recursion/globals against CPython; everything else in that sweep matched, including add(b=3, a=4), a default argument, recursion, global, a lambda in a name, and a list of lambdas indexed and called.

Measured 2026-07-30 — the value is WRAPPED, and the wrapper is what fails

PXXDBG=a.ir:mk on two programs that differ only in whether the returned def is nested:

returning a TOP-LEVEL def (works, prints 2):
  0: unknown  a=952                 tk=17     <- AN_PROCADDR
  1: lea      a=263 [sym=$pyresult]
  2: var_store a=1 b=0 c=17                   <- the ADDRESS goes straight out

returning a NESTED def (prints None):
  0: unknown  a=953                 tk=17     <- AN_PROCADDR
  1: arg a=0 b=3
  2: const_int ival=0  tk=13
  3: arg a=2 b=5
  4: const_int ival=1  tk=13
  5: arg a=4
  6: call a=799 b=1                 tk=17     <- WRAPPED: f(addr, 0, 1)

So mk does build and return something; the plain-address path is the one that works, and the wrapper's result is what the call site then cannot invoke. The capture count is 0 in this program (the middle argument), so the wrapper is being built even with nothing to capture — PyMakeBoundFnValue (pyparser.inc) is the shape to look at, and whether the call site knows the resulting tag.

print(mk()(1)) fails identically, so it is not about binding to a name.

Two neighbouring gaps found while narrowing, worth their own tickets

Gate

make test-nilpy + self-host byte-identical, plus returning a nested def with and without capture, storing one in a container, and passing one to a Callable[...] parameter.

Log