Integer and String Identity Surprises
One of the most reliably confusing experiences for a Python newcomer is watching
a is bflip betweenTrueandFalsedepending on which integer or string you chose, where you typed it, and how it was constructed.256 is 256isTrue, but two separately-computed257s are not the same object;"ab" is "ab"isTrue, but"a" * 2 is "ab"isFalse. None of this is random. It is the visible surface of three distinct caching/deduplication regimes — and, crucially, none of them is the bug. The actual bug is usingisto compare values at all. The identity operatoris“tests for an object’s identity:x is yis true if and only if x and y are the same object,” determined by theid()function (Python language reference, 6.10.3);==tests whether two objects have the same value. Confusing the two is the trap this note is about. The mechanics that makeis“work by accident” are covered in Object Identity and Interning and Small Integer and String Caching — this note is about the trap, its symptoms, and the fix.
Mental Model — Three Regimes, One Operator
The single most useful thing to internalize is that is on small immutable values does not have one behavior; it has three, and which one you observe depends entirely on how the operands came to exist. Think of is as asking “are these the literally-same object in memory?” and then realize there are three independent reasons two equal values might (or might not) share an object:
- The small-integer cache (a runtime singleton table). CPython pre-allocates every
intin the range[-5, 256]once at interpreter start-up and hands out the same object every time one is needed. Any two256s — however they were produced — are the same object. This regime is value-based and runtime-wide. - Compile-time
co_constsdeduplication (a per-code-object table). When the compiler sees two identical literal constants inside one function/module body, it stores the constant once in that code object’sco_conststuple and points both load sites at it. Soa = 257; b = 257written in the same function makesa is bTrue— not because 257 is cached, but because both names load the same compile-time constant. This regime is per-code-object and disappears across separate compilations. - Genuine runtime objects (no sharing). A value computed at runtime —
int("257"),200 + 57,"a" * 2,"".join(...)— that falls outside the small-int cache and isn’t a shared compile-time constant is a fresh object. Two such values are not the same object, even when equal.
flowchart TD Q["a is b — why True or False?"] --> R1{"int in [-5, 256]<br/>OR str interned?"} R1 -- yes --> CACHE["Same object:<br/>small-int cache /<br/>interned-string table<br/>(runtime singleton)"] R1 -- no --> R2{"Same literal constant<br/>in the SAME code object?"} R2 -- yes --> CONSTS["Same object:<br/>co_consts dedup<br/>(compile-time, per code object)"] R2 -- no --> FRESH["Distinct objects:<br/>genuine runtime values<br/>(is is False; == is True)"] CACHE --> EQ["== is the only reliable<br/>value comparison"] CONSTS --> EQ FRESH --> EQ
Diagram: the decision tree behind every “a is b was True/False and I don’t know why.” The insight to extract — the three branches are independent mechanisms (a runtime table, a per-code-object table, and “no sharing at all”), and all three converge on the same lesson: == is the only operator that gives a stable, meaningful answer for values. Use is only when the right-hand branch can never matter, i.e. for singletons.
The Canonical Surprises, Reproduced and Explained
Every example below was run on CPython 3.14.5 (3.14.5 (main, May 10 2026)). The small-integer cache range is [-5, 256] in 3.14 — _PY_NSMALLPOSINTS = 257 in the source, giving 256 down through −5. (The development branch toward 3.15 widened this to [-5, 1024], but that is unreleased; do not assume the wider range on any shipping interpreter. See Small Integer and String Caching for the version detail.)
1. 256 is 256 True, 257 is 257 False at runtime
>>> 256 is 256 # SyntaxWarning, but True
True
>>> 257 is 257 # SyntaxWarning; True only because of co_consts (see below)
True
>>> int("256") is int("256") # forces two *runtime* ints
True # 256 is in the cache -> same singleton
>>> int("257") is int("257") # two runtime ints, outside the cache
False # distinct fresh objectsThe reliable way to see the cache boundary is to force two genuinely separate runtime objects, which int("256") vs int("257") does — parsing a string never reuses a compile-time constant. int("256") returns the cached singleton both times (is → True); int("257") allocates a fresh PyLongObject each call (is → False). The boundary is exact: int("-5") is int("-5") is True, int("-6") is int("-6") is False.
2. "ab" is "ab" True, but "a" * 2 is "ab" False
>>> "ab" is "ab" # SyntaxWarning; both are the same compile-time constant
True
>>> ("a" * 2) is "ab" # left side built at runtime
False
>>> "".join(["a", "b"]) is "ab"
False
>>> import sys
>>> sys.intern("".join(["a", "b"])) is "ab"
TrueString literals that look like identifiers are interned by the compiler — folded into a global table of unique strings — and identical literals in the same compilation share one object, which is why "ab" is "ab" is True. But "a" * 2 and "".join([...]) produce strings at runtime; they are equal to "ab" but are not the interned object, so is is False. Calling sys.intern explicitly looks the runtime string up in (or adds it to) the intern table, after which is against the literal is True. The interning rules — which strings get auto-interned, and the fact that interned strings are mortal in 3.14 — live in Small Integer and String Caching and Object Identity and Interning; the point here is only that is on strings is a coin-flip unless you control interning.
3. The a = 257; b = 257 one-liner that is True
This is the example that breaks people’s mental model the hardest, because it seems to contradict example 1:
def f():
a = 257
b = 257
return a is b
>>> f()
True257 is not in the small-int cache, so this cannot be the cache. It is regime 2: compile-time co_consts deduplication. The compiler stores 257 exactly once in f.__code__.co_consts, and both a = 257 and b = 257 emit a load of that single constant — so a and b bind the same object. Disassembly makes it concrete:
>>> def g(): return 1000, 1000
>>> g.__code__.co_consts
(1000, (1000, 1000)) # 1000 appears once as a const; the tuple folds to one const too
>>> a, b = g()
>>> a is b
True # both came from the one shared 1000 constantCritically, this dedup is per code object. Compile the same literal twice separately and the two constants are distinct objects:
>>> ns1, ns2 = {}, {}
>>> exec(compile("x = 1000", "<s>", "exec"), ns1)
>>> exec(compile("x = 1000", "<s>", "exec"), ns2)
>>> ns1["x"] is ns2["x"]
FalseThis is also why the same expression behaves differently in the interactive REPL versus a script: in a script the whole module body is one code object (so two module-level 257s share a const and is is True), but in the classic REPL each statement is compiled separately, so two 257s typed on separate lines are different objects and is is False. The “it’s True in my file but False when I paste it line-by-line into the shell” report is exactly this regime boundary — not a Python bug, just two compilation units.
Why This Is a Trap — is Tests Identity, == Tests Value
The deep point: all of the above is a distraction. The bug is never “257 isn’t cached.” The bug is using is to ask a value question. is answers “same object?”; for value comparison you want “same value?”, which is ==. The CPython optimizations (small-int cache, interning, co_consts dedup) make is accidentally give the right answer for some values — which is the worst possible failure mode, because code that uses is for value comparison passes its tests on small integers and short literals, then silently breaks in production the first time a value crosses the cache boundary or arrives from a runtime computation, a file, a socket, or JSON.
A worked failure scenario. Imagine a status-code check:
def is_ok(code):
return code is 200 # BUG: 'is' for a value test
>>> is_ok(200) # literal 200 in caller's code object: may be True
>>> resp_code = int(socket_read()) # 200 parsed at runtime
>>> is_ok(resp_code) # 200 > -5..256? No — 200 IS in cache, so this works...
True
>>> def is_created(code): return code is 201
>>> is_created(int("201")) # still in cache (201 <= 256) -> True by luckThis particular bug is masked because 200 and 201 are inside [-5, 256]. Move the threshold to code is 500 and parse it at runtime, and the check silently returns False for a perfectly valid 500. The test suite — which almost always uses literal 500 — keeps passing because the literal participates in co_consts dedup, while the runtime value does not. The result is a check that works in tests and fails in production, the textbook signature of an identity-vs-value confusion. The fix is one character: code == 500.
The Correct Rule
The rule is short and absolute:
- Use
is/is notonly for singletons and sentinels:None,True,False, and your own unique sentinel objects (e.g._MISSING = object()). These have exactly one instance each, so identity is the right question. PEP 8, quoted in the language reference, advises that “comparisons to singletons likeNoneshould always be done withisoris not, never the equality operators” (reference, 6.10.3) — and the converse is just as binding: never useisfor anything that isn’t a singleton. - Use
==/!=for every value comparison: integers, strings, floats, tuples, dataclasses — anything where you mean “same value,” which is almost always.
Why is None rather than == None? Two reasons. First, None is a guaranteed singleton, so identity is exact and there is no instance to allocate or cache-miss on. Second, == can be overridden: a class can define __eq__ so that obj == None returns True (or raises, or does something expensive), whereas obj is None cannot be intercepted — it is a pure pointer comparison in the interpreter. For a sentinel check you want the un-overridable, allocation-free identity test. The same reasoning makes is the correct operator for custom sentinels created with object(), where you specifically want “is it this exact marker,” not “does it equal something.”
How Linters and the Interpreter Flag It
You are not on your own here — both the interpreter and the standard linter catch the literal form.
CPython’s SyntaxWarning. Writing is against a literal constant triggers a compile-time SyntaxWarning. This diagnostic was added in Python 3.8 — the 3.8 What’s New states that “the compiler now produces a SyntaxWarning when identity checks (is and is not) are used with certain types of literals” (What’s New in Python 3.8), implementing bpo-34850 / gh-79031, “Emit a syntax warning for ‘is’ with a literal” (closed 2019-01-18, during the 3.8 cycle). Verified live on 3.14.5, the warning fires for int, str, tuple, bytes, and float literals, and not for None/True (legitimate targets) or non-constant expressions like [1]/{}:
<stdin>:1: SyntaxWarning: "is" with 'int' literal. Did you mean "=="?
<stdin>:1: SyntaxWarning: "is" with 'str' literal. Did you mean "=="?
<stdin>:1: SyntaxWarning: "is" with 'tuple' literal. Did you mean "=="?Because it is a SyntaxWarning, it is “typically emitted when compiling Python source code, and usually won’t be reported when running already compiled code” (exceptions docs) — i.e. you see it when the .py is first compiled, not necessarily on every run from a cached .pyc. Under warnings.simplefilter("error") (or python -W error) it is promoted to a hard SyntaxError, which is a good setting for CI.
Ruff F632 / Flake8 / Pyflakes. The lint rule F632, “Use == to compare constant literals (str, bytes, int, float, tuple),” flags exactly this pattern and offers an autofix. Run against if x is 5: and if x is "str": on ruff 0.12.7:
F632 [*] Use `==` to compare constant literals
if x is 5:
^^^^^^ F632
= help: Replace `is` with `==`Ruff correctly leaves if x is None: alone. The rule’s own caveat is worth knowing: it only catches the literal form. x is y where both are runtime variables that happen to be equal cannot be caught statically — that one needs a human or a type-aware reviewer. So the linter closes the easy door (is 257); the hard door (a is b for two runtime ints) is closed only by knowing the rule and never reaching for is on values in the first place.
Common Misunderstandings
- “
isis faster than==, so I’ll use it for speed.”isis a pointer comparison and==may call__eq__, soisis cheaper — but using it for a value test is simply wrong, and “fast and wrong” is not a trade-off. For the integers and strings where the optimizations makeisaccidentally correct,==is already extremely fast (small-int==short-circuits on the cached singleton anyway). Never trade correctness for nanoseconds here. - “Interning means all equal strings share an object.” No. Only some strings are auto-interned (identifier-like literals); runtime-built strings are not, until you
sys.internthem. See Small Integer and String Caching. - “
x is 257failed once, so I’ll widen my code to handle the cache.” You are solving the wrong problem. The cache boundary is an implementation detail that can (and did) move between releases; relying on it — even to avoid it — is fragile. Just use==. - “Empty tuple is a singleton, so
isis fine for it.”() is ()isTruein CPython (the empty tuple is a singleton), but(1,) is (1,)isFalse, andfrozenset() is frozenset()isFalse(that singleton was removed in Python 2.6). Don’t generalize from one cached empty container to “containers can be compared withis.”
See Also
- Object Identity and Interning — the mechanism: what
id()returns, the intern table, which strings are auto-interned (R1). - Small Integer and String Caching — the
[-5, 256]small-int cache and the string cache in full, with the 3.14-vs-3.15 range detail (R2). - Truthiness and Falsy Values — the sibling
is/==/if x:confusion, for booleans and emptiness. - Reference vs Value Semantics in Python — the broader identity-vs-value theme.
- CPython Integer Internals — why large ints are fresh
PyLongObjects with 30-bit digits. - Python Internals MOC — §16 Common Gotchas.