Run it
The cache boundary, the folding that hides it, and the nan case where equality and identity openly disagree.
The cache boundary - and the constant folding that hides it.
For anything mutable the two never agree unless you aliased on purpose.
And the case where the two operators openly disagree.
Two different questions
a == b calls a.__eq__(b), which a type defines however it likes. a is b compares the addresses of the two objects and cannot be overridden by anything.
So == is a question about values and is is a question about storage. Confusing them usually works, which is the problem: the code passes its tests on small inputs and fails on a value that happens to fall outside a cache.
The small-integer cache, and the folding on top of it
CPython pre-creates every integer from −5 to 256 at startup and hands out the same object whenever one is needed. So int('256') is int('256') is True and int('257') is int('257') is False.
There is a second, separate mechanism that makes this hard to demonstrate. Constants written in one code block are folded by the compiler, so e = 257; f = 257 puts one constant in the code object and e is f is True — nothing to do with the cache, and it is why the same experiment gives different answers in a script and at a REPL prompt.
Short strings that look like identifiers get the same treatment through interning. The lesson is not the boundaries; it is that these are decisions an interpreter is free to change.
Where is belongs
Three cases, and essentially nothing else.
Singletons. x is None, x is True, x is False. There is exactly one of each, so identity is the correct test and it is faster than == besides.
Sentinels. _MISSING = object(), then if arg is _MISSING. The whole point of the object is that nothing else can be it.
Genuine aliasing questions. "Are these two names the same list?" is an identity question, and it is what you want when checking whether a caller handed you the object you already hold.
The follow-up: == that disagrees with itself
"Can x == x be False?" Yes. float('nan') == float('nan') is False by IEEE rule, and so is nan == nan for the same object — while nan is nan is True. That is the cleanest possible demonstration that the two operators are answering different questions, and it is why x in [float('nan')] can be True: the container check tries is first as a shortcut.
What to say out loud
== compares values through __eq__; is compares object identity. They agree on small ints and interned strings because CPython caches them, which is an implementation detail I would never rely on. I use is only for None, True, False and sentinel objects.
Then stop. The commonest failure on a question like this is answering it correctly in one sentence and then talking for another minute until something wrong comes out.
What to notice while it runs
int('256') twice gives one object; int('257') gives two.
- Writing
257 twice as literals gives one object again — that is the compiler, not the cache.
- For lists,
== and is never agree unless you aliased deliberately.
Edge cases to raise
Volunteering these is most of what separates a correct answer from a good one.
The cache boundaries are not a language guarantee. PyPy, MicroPython and a future CPython are free to differ, so any behaviour that depends on them is a bug that happens to pass today.
x is not None reads better than not x is None and is the same operator. Worth using, because the second form invites a misparse.
Do not use is to test for emptiness or truth. if x is True fails for 1, for a non-empty list, and for anything with __bool__ - which is almost certainly not what you meant.
The follow-ups interviewers ask
"Should you ever use is on strings?" No, outside of sentinels. Interning makes it appear to work for short literals and it stops working for anything computed at runtime, which is the worst possible failure pattern - correct in development, wrong on real input.
"What about == on floats?" A different trap with the same shape: 0.1 + 0.2 == 0.3 is False. Use math.isclose, and mention it unprompted if the question involves numeric comparison at all.
"How do you make == work on your own class?" Define __eq__ - and then __hash__, because defining __eq__ alone sets __hash__ to None and makes instances unhashable. Two objects that compare equal must hash equally, or they break dictionaries. @dataclass(frozen=True) generates both correctly.
Common wrong answers
"is is faster, so use it for comparisons." It is faster, and it answers a different question. Speed is not a reason to ask the wrong thing.
"Small integers are interned up to 1000." −5 to 256. Quoting a wrong boundary confidently is worse than saying "a small range, and I would not depend on it".
"== always calls __eq__." Container membership checks identity first as a shortcut, which is why nan in [nan] is True while nan == nan is False.
Recap in one screen
- int('256') twice gives one object; int('257') gives two.
- Writing 257 twice as literals gives one object again - that is the compiler, not the cache.
- For lists, == and is never agree unless you aliased deliberately.
- The one-line answer: == asks "same value", is asks "same object".
- Worth trying: Change 257 to 256 in the int() lines and watch is flip. Then try -6, which is one below the cache at the other end.
- Worth trying: Compare "hello" is "hello" with "hello world" is "hello world". Identifier-like strings are interned; ones with a space often are not.