str(None) prints 0, but str(x) with x = None prints None
x = None
print(str(None)) # CPython: None pxx: 0
print(str(x)) # CPython: None pxx: None <- correct
print("v=" + str(None)) # CPython: v=None pxx: v=0
print(None) # CPython: None pxx: None <- correct
A LITERAL None handed to str() is typed as integer 0 and formatted as such.
Bound to a name first it is a variant carrying the None sentinel and formats
correctly, and bare print(None) is correct too — so only the
literal-straight-into-str() route is wrong.
Silent: a "prefix" + str(None) in a message produces prefix0, which reads
like data rather than like a bug.
This is the literal end of the None-representation family ([[project_nilpy_none_routes_sentinels.md]]): the sentinel is right everywhere it flows through a variant, and wrong where a literal is typed directly.
Found by sweeping builtins over argument types against CPython.
Located 2026-07-30 — and why it was PARKED rather than fixed
The None literal is built in ParseFactor (parser.inc, the tkNil arm):
tkNil:
begin
node := AllocNode(AN_INT_LIT); ASTIVal[node] := 0;
ASTTk[node] := Ord(tyPointer); CurASTNode := node; Next;
LastExprTk := tyPointer;
end;
So a bare None is the integer 0, and str()'s overload set picks the numeric
member. Everywhere None already behaves correctly, a caller special-cases the
token FIRST and substitutes PyMakeNone — the tuple-element path
(pyparser.inc ~6669), the keyword-argument path (~7130), print (~7749) and
several more. str()'s argument is simply not one of those sites.
The tempting fix — make the tkNil arm yield PyMakeNone under PyExprMode, so
every position gets it for free — is NOT safe as a drive-by. Several paths
depend on the literal being a SCALAR: ir.inc's class-rebind ARC arm fires on
d = None precisely because the right-hand side's type kind is in the scalar
set, and Optional[int] deliberately maps None to a 0 sentinel
([[bug-nilpy-none-equals-zero-is-true]] documents that trade). Turning the
literal into a variant everywhere changes all of them at once, at the far end
of a 20-minute gate.
Two honest options for whoever picks this up:
- Narrow: substitute
PyMakeNoneat the call-argument site only, next to the sites that already do it. Fixesstr(None)andf(None)generally, touches nothing else. - Blanket: change the
tkNilarm, and audit every consumer that readsASTTkof a None right-hand side — the ARC rebind arm,Optional[int]widening, and theis Nonecomparisons — in one deliberate pass. This is the better end state and is really the same question as [[bug-nilpy-none-equals-zero-is-true]]; do them together.
Gate
make test-nilpy + self-host byte-identical, plus a regression covering
str(None), str(x), "" + str(None) and f-string/% interpolation of a
literal None.
Log
- 2026-07-30 — resolved, commit d68612d6e.