Why Python's is and == are not the same thing, how one of them lies about numbers, and the single rule that keeps you safe.
I had a check that compared two numbers. Something close to this, simplified:
if parsed_id is expected_id:
grant_access()
It worked. Every test passed. Then one day, with slightly different data, the same comparison started returning False for two numbers that were obviously equal. Both were 257. I printed them. They were identical. And parsed_id is expected_id was False.
So I changed the test data, and it went back to True. Smaller numbers worked. Bigger numbers did not. The comparison's correctness depended on how large the number was, which is not a sentence that should ever make sense.
The culprit was a single two-letter word I had used where I meant something else: is. In Python, is and == look interchangeable and are not. One asks "are these equal?" The other asks a completely different question, and the answer to that question happens to be unpredictable for numbers.
Here is what is actually checks, why it lied to me, and the one rule that makes this disappear.
The Two Questions Are Not the Same Question
== and is ask different things, and the whole bug lives in the gap between them.
-
==asks: are these two values equal? Do they represent the same thing?5 == 5isTruebecause five equals five. This is almost always the question you actually want. -
isasks: are these two the exact same object in memory? Not "equal," but "literally the same object, the same identity."
Two different lists that happen to hold the same items are equal but are not the same object:
a = [1, 2, 3]
b = [1, 2, 3]
a == b # True -> same contents
a is b # False -> two different list objects
a and b are equal. They are not identical. They are two separate lists that look alike. == sees them as equal; is sees them as distinct, because they are distinct objects sitting at different places in memory.
That is the core confusion:
isis not a stricter==. It is a different operator answering a different question.
And for most values you compare, you care about equality, not identity. You want ==.
Why is Lied About My Numbers
So why did parsed_id is expected_id ever return True for 257, let alone sometimes?
Because of an optimization most people never learn about. For performance, CPython keeps a cache of small integer objects, roughly -5 through 256, created once at startup and reused everywhere. Every time you use the number 5, you get back the same cached 5 object. So for small numbers, two equal values really are the same object, and is returns True by luck.
Step outside that cached range and the luck runs out. Numbers above 256 are not cached, so two equal large numbers can be two different objects, and is returns False.
Here is the trap in its purest form, which I ran on Python 3.13:
int('256') is int('256') # True
int('257') is int('257') # False
Read that again. The exact same code. The only difference is 256 versus 257. One is True, the other is False, purely because 256 is in the small-integer cache and 257 is not.
That is why my check worked for small IDs and broke for large ones. is was never checking equality. It was checking identity, and identity for numbers depends on an internal caching optimization that you are not supposed to rely on and that can change between Python versions and even between how the values were produced. I had built a correctness check on top of an implementation detail.
A note, because it makes the danger worse: with plain literals written in your source (257 is 257), Python's compiler often folds them into one shared constant, so it returns True and looks fine. The failure only appears when the numbers are computed at runtime, as mine were, from parsing. So it can look correct in a quick REPL test with literals and then fail on real, computed data. Exactly the data you did not test with.
Why Python Itself Now Warns You
This mistake is common enough that recent versions of Python will warn you about one form of it. Compare a value to a literal with is and the interpreter says:
SyntaxWarning: "is" with 'str' literal. Did you mean "=="?
The language is telling you, out loud, that is is almost certainly not what you meant when comparing to a literal string or number. If you see that warning, the fix is virtually always to change is to ==. Python is trying to save you from the exact bug I shipped.
The One Place is Is Not Only Fine but Correct: None
Here is where it flips. There is a specific, important case where is is the right operator and == is subtly wrong: comparing to None.
The official Python style guide, PEP 8, states it directly:
Comparisons to singletons like None should always be done with is or is not, never the equality operators.
Always x is None, never x == None. Why? Two reasons.
First, None is a singleton. There is exactly one None object in the entire program, so "is this the same object as None" is precisely the right question, and is answers it directly and fast.
Second, and more important, == can be overridden. Any object can define its own __eq__ method and decide what it considers equal to. A badly behaved or cleverly designed object can make x == None return True when x is not actually None. I confirmed exactly this:
class Weird:
def __eq__(self, other):
return True
w = Weird()
w == None # True <- __eq__ hijacked the comparison
w is None # False <- correct: w is genuinely not None
w == None is True, which is a lie. w is None is False, which is the truth. is cannot be fooled this way, because identity is not something an object can redefine. That is why checking for None is the one case where you reach for is on purpose.
So the operators split cleanly by use case:
| Comparing to... | Use | Why |
|---|---|---|
None (and other singletons like True/False when you truly mean the object) |
is / is not
|
One object exists; == can be overridden and lie |
| Numbers, strings, lists, any value |
== / !=
|
You care about equality, not identity |
The Rule I Take Away
Compressed to one line:
==asks "are these equal?"isasks "are these the exact same object?" Use==for comparing values. Useisonly forNoneand other singletons. Never useisto compare numbers or strings, because its answer depends on caching and interning you must not rely on.
The deeper lesson, the one that outlasts this specific trap, is that identity and equality are different concepts, and the two-letter word is means identity, not "equals."
English makes is feel like a synonym for equality, which is exactly why people reach for it when they mean ==. But Python's is is a precise, narrow operator about object identity, and for values like numbers that identity is governed by internal optimizations that are not part of the language's promises. Build a comparison on is for a value, and you have built it on sand that shifts between numbers, between runs, and between Python versions.
And the usual sting applies. This hides in testing because small numbers and literal values make is accidentally return True, so it looks correct right up until real, computed, larger data flows through and the identity you were never entitled to quietly stops holding.
How to Catch It Before It Catches You
Keep these four habits:
-
Use
==to compare values, always. Numbers, strings, anything where you care about the value being equal. If the question is "do these represent the same thing," that is==, every time. Reaching forison a value is the bug. -
Use
isonly forNoneand singletons.if x is None,if x is not None. This is both correct and the officially recommended style (PEP 8). The same applies if you genuinely mean the oneTrueorFalseobject, but forNoneit is essential, because==can be overridden and lie. -
Heed the
SyntaxWarning. If Python warns"is" with a literal. Did you mean "=="?, the answer is yes, you did. Change it to==. -
Never test identity-based comparisons only with small numbers or literals.
isgives false confidence with cached small ints and folded literals. If you must reason aboutis, test with large, runtime-computed values, where the caching no longer rescues you.
I changed one is to == in that access check, and the comparison started telling the truth for every number, large or small. The two values had been equal the whole time. I had just been asking Python the wrong question, and for small numbers it had been politely giving me the right answer anyway, right up until it stopped.
The 30-Second Takeaway
-
==andisask different questions.==asks "are these values equal?"isasks "are these the exact same object in memory?" They are not interchangeable. -
Two equal objects can fail an
ischeck:[1,2,3] == [1,2,3]isTrue, but[1,2,3] is [1,2,3]isFalse, because they are two distinct objects. -
islies about numbers because of small-integer caching. On Python 3.13:int('256') is int('256')isTrue(256 is cached), butint('257') is int('257')isFalse(257 is not). Same code, different answer by size. This is an implementation detail you must never rely on. -
It hides in testing: small numbers and source literals make
isaccidentally returnTrue(caching and compiler constant-folding). It breaks only on large, runtime-computed values. -
The one correct use of
is:None. PEP 8 says comparisons to singletons likeNoneshould always useisoris not.Noneis a singleton, and==can be overridden to lie (w == Nonecan beTruewhilew is NoneisFalse). -
The rule:
==for comparing values;isonly forNoneand singletons; neverisfor numbers or strings. And if Python warns"is" with a literal, change it to==.
Your Turn
Search your code for is and is not and check each one: is the right-hand side None (good) or a number, string, or other value (bug)? Every is comparing a value should be ==. The ones comparing to None are exactly right.
Did is vs == ever bite you in production? Tell me in the comments 👇
If this was useful, follow me here for more stories from the backend trenches, and subscribe to The Backend Tech Lead on Substack for weekly blueprints.
Top comments (0)