Python has two ways to ask whether things are “the same.” a is b asks whether both names refer to the very same object in memory. a == b asks the object whether it considers itself equal to the other one, which is whatever its class’s __eq__ says. Most of the time you want ==. The interview questions on this topic are about the cases where the two give different answers, and why code that uses is for numbers or strings seems to work until it suddenly doesn’t.
Two lists, two questions
Start with the case that shows the difference cleanly:
a = [1, 2, 3]
b = a
c = [1, 2, 3]
print(a == b, a is b, a == c, a is c)True True True Falseb = a doesn’t copy anything; it gives the same list a second name, so a is b is True. c is a separate list that happens to hold equal values: equal, but not identical. The distinction matters as soon as you mutate:
c.append(4)
print(a, c)
b.append(4)
print(a)[1, 2, 3] [1, 2, 3, 4]
[1, 2, 3, 4]Appending to c left a alone. Appending to b changed a, because there’s only one list. If you’ve ever had a function “mysteriously” change a caller’s data, this is the mechanism; the shallow copy guide follows it into nested structures.
Identity is tied to id(): a is b is equivalent to id(a) == id(b) while both objects are alive. In CPython, id() is the object’s memory address. Once an object is freed, a new one can reuse that address, so comparing id() values of objects that weren’t alive at the same time tells you nothing.
Why is seems to work for small numbers
This is the classic interview trap:
x = int("256"); y = int("256")
print(x is y)
x = int("257"); y = int("257")
print(x is y, x == y)True
False TrueCPython keeps a cache of small integers, -5 through 256, created at startup. Any computation that produces one of those values returns the cached object, so two separately computed 256s are the same object. 257 isn’t cached, so each int("257") builds a new object. The values are equal; the identities aren’t.
The example uses int("...") on purpose. If you write x = 257; y = 257 in one script, you’ll often get True, because the compiler stores identical constants in a code object only once. That’s another optimisation layered on top, and it’s why people are so often convinced that is works for numbers: it does, in their quick test. Neither behaviour is guaranteed by the language. PyPy and other implementations do it differently, and CPython has changed these details between versions.
Python now warns you directly:
x = 1000
print(x is 1000)ie2.py:2: SyntaxWarning: "is" with 'int' literal. Did you mean "=="?
print(x is 1000)
TrueIt printed True (constant sharing again) and warned anyway, because the result isn’t something you can rely on. Treat that warning as an error in code review.
Strings and interning
Strings behave similarly. CPython interns some strings automatically, mostly ones that look like identifiers and appear as literals in code. Strings built at runtime aren’t interned:
s1 = "".join(["order", "-", "id"])
s2 = "order-id"
print(s1 == s2, s1 is s2)
import sys
print(sys.intern(s1) is sys.intern(s2))True False
TrueEqual strings, different objects. sys.intern returns a canonical copy, which can save memory when you hold millions of repeated strings (log field names, say), but it’s an optimisation tool, not a way to make is comparisons correct. Compare strings with ==.
When is is the right tool
Use is when you mean identity, which in practice means singletons.
None. There’s exactly one None object, and PEP 8 says to compare with is None / is not None. The reason isn’t speed. == hands control to the left operand’s __eq__, which can do anything:
class AlwaysEqual:
def __eq__(self, other):
return True
v = AlwaysEqual()
print(v == None, v is None)True FalseA real-world version: in NumPy and pandas, arr == None compares element-wise and returns an array, and using that in an if raises ValueError: The truth value of an array ... is ambiguous. arr is None always returns a plain bool. (NumPy isn’t installed in the environment used for this article, so that error message is quoted from NumPy’s documented behaviour rather than run here.)
Sentinels. When None is a valid value, create a unique object and check it by identity:
_MISSING = object()
def get(d, key, default=_MISSING):
value = d.get(key, _MISSING)
if value is _MISSING:
if default is _MISSING:
raise KeyError(key)
return default
return value
print(get({"a": None}, "a"), get({}, "b", 0))None 0{"a": None} returns None correctly instead of being treated as “missing,” which is exactly what if value is None would get wrong.
True, False, NotImplemented, Ellipsis are also singletons, but you rarely compare to True at all; if flag: is the idiom.
Writing eq properly
Because == is a method call, your classes decide what it means. Two details trip people up. First, return NotImplemented (not False) for types you don’t handle, so Python can try the other operand’s __eq__:
class Money:
def __init__(self, amount):
self.amount = amount
def __eq__(self, other):
if not isinstance(other, Money):
return NotImplemented
return self.amount == other.amount
print(Money(5) == Money(5), Money(5) == 5, Money(5) != Money(6))True False TrueWhen both sides return NotImplemented, Python falls back to identity, so Money(5) == 5 is False without an exception. Python 3 also derives != from __eq__ automatically. Second, defining __eq__ sets __hash__ to None, so instances stop working as dict keys until you define a matching __hash__ (see hashability and dictionary keys).
Containers mix both checks. x in some_list tests item is x or item == x for each element. That’s why float("nan") in [nan] is True for the same NaN object, even though nan == nan is False.
Interview exercise: the flaky status check
A service has if response.status is 200: in its retry logic. It worked in every test, but in production some successful responses are retried. What’s happening?
Answer
is compares identity, and 200 falls inside CPython’s small-int cache, so most of the time the status object really is the cached 200 and the check passes. But the cache only applies to integers that come back as the cached object. If the status arrives as a different type that compares equal, such as an IntEnum member like HTTPStatus.OK or a value from a C extension or deserialiser that builds its own int subclass, == would say equal and is says not. You can check it in a shell: HTTPStatus.OK is 200 is False, while HTTPStatus.OK == 200 is True. Tests that build responses from literals never exercise that path. The fix is if response.status == 200: (or == HTTPStatus.OK). The bigger lesson is that is with a literal is always a bug, which is why Python emits a SyntaxWarning for it, and a linter rule (F632 in Ruff and pyflakes) catches it before review.