Ch. 9 · Python

Python is vs ==: Identity, Equality and When to Use Each

is checks for the same object and == asks the object whether values match. Small-int caching, interning, None checks and sentinels.

~5 min readbeginnerupdated Oct 7, 2026

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)
python
True True True False
Text

b = 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)
python
[1, 2, 3] [1, 2, 3, 4]
[1, 2, 3, 4]
Text

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)
python
True
False True
Text

CPython 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)
python
ie2.py:2: SyntaxWarning: "is" with 'int' literal. Did you mean "=="?
  print(x is 1000)
True
Text

It 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))
python
True False
True
Text

Equal 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)
python
True False
Text

A 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))
python
None 0
Text

{"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))
python
True False True
Text

When 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.

More in Python

read ✓Python · hard

Python asyncio.gather and Timeouts

What asyncio.gather does when one task fails or a deadline passes, and when to use return_exceptions, asyncio.timeout, wait or TaskGroup.

~7 min readread →
esc