Why does `is` agree with `==` for some numbers and strings but not others?
Because `is` compares identity while `==` compares value, and CPython reuses objects for small integers and for compile-time string constants. The agreement is an implementation detail of the interpreter you happen to be running, so code that relies on it works until the values change.
What the interviewer is scoring
- Does the candidate define `is` as an identity test rather than as a faster equality test
- Whether the small-integer cache is attributed to CPython rather than to the language
- That compile-time constant folding is offered as the reason two identical literals can be one object
- Whether the legitimate uses of `is` are named precisely
- Does the candidate know that containers apply an identity shortcut before calling `__eq__`
Answer
Two different questions
== asks whether two objects have the same value, which means calling __eq__ and letting the type decide what sameness means. is asks whether two names refer to the same object, which is a pointer comparison that no type can influence and no user code participates in. They are unrelated tests that happen to give the same answer often enough for people to treat is as a fast ==.
Everything confusing here comes from one optimisation. Immutable objects with the same value are indistinguishable in use, so an interpreter is free to hand out one object where the program asked for two, and CPython does this in several places for speed and memory. When it happens, is returns True and appears to be testing value.
Why 256 is one object and 257 is not
CPython pre-creates the integers from -5 to 256 when the interpreter starts and returns those same objects whenever a value in that range is needed. Arithmetic producing 100 does not allocate a new integer, it returns the cached one, so two independently computed hundreds are the same object. Outside that window each result is a freshly allocated integer.
a = 200 + 56
b = 256
a is b # True: both are the cached 256
c = 200 + 57
d = 257
c is d # False: two separate integer objects
The range is not part of the language. It is a decision inside one implementation of it, chosen because small integers dominate real programs, and nothing prevents another implementation, or another release, from choosing differently. That is the reason the behaviour is uninteresting as a fact and important as a warning.
Compile-time constant folding is a second and separate source of coincidental identity. Constants inside a single code object are stored once, so two occurrences of the same literal in one function body can be the same object even far outside the cached range. Type the same two assignments as separate statements at an interactive prompt and each is compiled on its own, so they are not. The same expression therefore gives different answers depending on whether it sits in a function, a module or a REPL, which is precisely the sort of behaviour you do not want a conditional to depend on.
Strings are interned by the compiler, not by the runtime
Strings work the same way with a different rule. CPython interns string constants that look like identifiers — letters, digits and underscores — because those are overwhelmingly attribute and variable names, and interning them makes every attribute lookup a pointer comparison instead of a character-by-character one. So two occurrences of "user_id" in your source are usually one object, and is appears to work on strings.
Strings built at run time are not interned. Reading a field from a database, decoding JSON, slicing, or concatenating produces a fresh object each time, so is on them fails even when the characters are identical. This is why the bug shows up in production rather than in a test: the literal-versus-literal comparison a developer wrote and checked by hand is interned, and the literal-versus-parsed-input comparison the code performs at run time is not.
If you genuinely want the memory saving on a high-cardinality repeated string, sys.intern asks for it explicitly. That is a memory optimisation for a dictionary of millions of repeated keys, not a licence to compare with is afterwards.
Since Python 3.8 the compiler emits a SyntaxWarning when is is used against a literal, which catches the crudest form of this. It cannot catch the general case, because comparing two variables is legal and the interpreter has no way to know you meant equality.
Where identity is exactly the right test
The reaction to all of this should not be to avoid is, because for some checks it is the only correct operator.
x is None is right because None is a singleton and, more importantly, because a custom __eq__ can return True for a comparison against anything at all, including None, so x == None is a question the object gets to answer. The same argument makes is correct for your own sentinel objects, the ones you create precisely so that "no value supplied" is distinguishable from a legitimately passed None. Enum members are singletons within a process and comparing them with is is idiomatic and safe.
The mirror image is is True, which is almost always wrong. It is not a truthiness test, and it is not even an equality test: it succeeds only for the actual boolean object, so it rejects 1, a non-empty list, and a NumPy boolean scalar. If you want truthiness, write if x.
The identity shortcut hiding inside containers
There is one more consequence worth knowing, because it produces behaviour that looks like a bug in the language. When CPython compares elements inside a container — for in, for list.index, for comparing two lists — it checks identity first and only calls __eq__ if the objects differ. That shortcut is invisible for well-behaved types, and it is observable for a value that is not equal to itself:
n = float("nan")
n == n # False, as IEEE 754 requires
n in [n] # True: the container found the same object and stopped
n in [float("nan")] # False: a different object, so __eq__ decides
Nothing here is broken. It is the same principle as the integer cache seen from the other side: identity and equality are separate questions, and code that assumes they answer together will be right most of the time and wrong in the cases that reach production.
isis not a faster==. The times it appears to work on numbers and strings are CPython reusing immutable objects, which is an optimisation you may observe but must never depend on. ReserveisforNone, for sentinels and for enum members.
Likely follow-ups
- Why does `float('nan') in [float('nan')]` behave differently depending on how you build the list?
- When is `sys.intern` worth calling deliberately, and what does it cost you?
- Why is `is None` the correct test but `is True` usually the wrong one?
- How can a class make `==` and `is` disagree in a way that breaks a dict lookup?
Related questions
- If you override equals but not hashCode, what breaks and when?mediumAlso on immutability4 min
- A list of a million small objects uses far more memory than the data inside them. Where is it going?mediumAlso on cpython5 min
- How would you design a thread-safe component, and why is adding synchronized to every method not a design?hardAlso on immutability7 min
- When is a factory the right answer, when is a builder, and when should you just call the constructor?mediumAlso on immutability7 min
- How would you design a ledger so that any balance can always be proved?hardAlso on immutability5 min
- When would you make a type a record, a struct, or a class? What does each choose for you?mediumAlso on equality4 min
- Implement a FIFO queue using only two stacks, then tell me what a dequeue costs.mediumSame kind of round: concept4 min
- Count the set bits in a 32-bit integer. Now tell me what your loop does when I hand it a negative number.mediumSame kind of round: concept4 min