is vs ==, the small-int cache, and string interning
==compares value (calls__eq__).iscompares identity (same object in memory, sameid()).
There are two completely different mechanisms that can make is return True for equal-looking literals,
and mixing them up is why "small int caching" advice online is so inconsistently reproducible. This
notebook demonstrates both, and — importantly — it's being run in Jupyter/IPython, which compiles each
top-level statement in a cell separately. A plain .py script compiles its whole file as one code
object instead, so a couple of these results genuinely come out different in a script vs. here. That
difference is real and verified below, not a caveat to wave away.
Mechanism 1: the small-int cache (-5..256) -- a genuine runtime singleton, same everywhere
# Python 3.8+ warns on `is` against an int/str literal, since it's almost always a mistake.
# We're deliberately doing it on purpose here to demonstrate identity semantics, so silence that warning.
import warnings
warnings.filterwarnings("ignore", category=SyntaxWarning)
a = 256
b = 256
print("256 is 256:", a is b, "|", a == b)
e = -5
f = -5
print("-5 is -5: ", e is f, " <- both in the cached range, always True, script or notebook")
Mechanism 2: compile-time constant folding -- only within ONE code object
Outside the cache, is can still return True for equal integer literals, but only because the compiler
deduplicated them into one shared constant within a single code object. Two literals in the same
statement/expression always share a code object, so this folds regardless of environment:
print("within ONE statement:", 257 is 257, " <- both literals compiled together, folded, always True")
But two literals in separate statements only share a code object if the whole surrounding block was
compiled as one unit. A .py script's top-level statements ARE one unit (verified separately, outside this
notebook, via python3 script.py: c = 257; d = 257 on two lines printed True). Jupyter/IPython compiles
each top-level statement in a cell on its own, so that sharing does NOT happen here -- watch this actually
run, in this exact notebook:
c = 257
d = 257
print("257 is 257, separate statements, IN THIS NOTEBOOK:", c is d, " <- False here; a .py script would print True")
g = -6
h = -6
print("-6 is -6, separate statements, IN THIS NOTEBOOK: ", g is h, " <- outside the cache AND no folding across statements")
eval() always compiles a brand new, separate code object no matter what -- never shares constants with anything, in a script or a notebook:
x = 257
y = eval("257")
print("257 vs eval('257'):", x is y)
Mechanism 3: automatic interning of identifier-shaped string literals -- also a runtime thing, same everywhere
CPython automatically interns string literals that look like an identifier (letters/digits/underscore
only, no spaces) as a genuine runtime optimization -- independent of which code object compiled them. This
is why "hello" behaves differently from "hello world" even across separate statements, in ANY
environment:
s1 = "hello"
s2 = "hello"
print("'hello' (identifier-shaped), separate statements: ", s1 is s2, " <- real auto-interning, not folding")
s5 = "hello_world"
s6 = "hello_world"
print("'hello_world' (identifier-shaped), separate statements:", s5 is s6, " <- also auto-interned")
s3 = "hello world"
s4 = "hello world"
print("'hello world' (has a space), separate statements, HERE:", s3 is s4, " <- False here; NOT auto-interned, and no cross-statement folding in a notebook (a script would print True)")
s7 = "".join(["hel", "lo"])
print("runtime-built 'hello' vs the literal: ", s7 is s1, "| but equal:", s7 == s1, " <- interning never applies to strings built at runtime")
Takeaway: never rely on is for value equality. Even setting that aside, "is this cached/interned"
has three independent answers depending on which mechanism applies (runtime singleton cache, compile-time
folding within one code object, or runtime auto-interning of identifier-shaped strings) -- and the
compile-time one genuinely gives different results in a script vs. a notebook, which is exactly the kind of
thing that makes is-based micro-optimizations unreliable across environments.