if status is "active":
...
This passes its tests. Every one of them. Then the same code receives a status that came out of json.loads instead of a literal, and silently takes the other branch. No exception, no log line, just a request that quietly did the wrong thing.
The three guarantees
The Python FAQ is unusually direct about this. Identity is guaranteed in exactly three circumstances:
-
Assignment. After
b = a,a is b. -
Container storage. After
container.append(a), the item you get back is the same object:container[-1] is a. -
None. There is oneNone, sox is Noneis always the right test.
And then the sentence that matters: “identity tests should not be used to check constants such as int and str which aren’t guaranteed to be singletons”.
So x is None, x is True, x is False, x is SENTINEL where SENTINEL = object(), and type(x) is Foo are all fine. x is 0, x is "", x is "active" and x is () are all bets on implementation detail.
Since Python 3.8 the compiler emits a SyntaxWarning for is against a literal, which catches the most blatant form. It does not catch x is EXPECTED_STATUS where EXPECTED_STATUS = "active" is a module constant, which is the form that survives review.
The demonstration that should bother you
Paste this into a file and run it:
x = 1000
y = 1000
print(x is y) # True
Now put the same three lines inside a function and call it. Still True. Now put them in a REPL, one line at a time:
>>> x = 1000
>>> y = 1000
>>> x is y
False
Nothing about the semantics changed. What changed is that in a single compilation unit — one module, one function body — the compiler folds equal constants into one entry in the code object’s constant table, so both names end up bound to the same object. In the REPL each statement is its own compilation unit, so there are two objects.
The famous -5..256 small-integer cache is a different mechanism that produces the same class of surprise, and here is the part worth internalising: neither the small-int cache nor automatic string interning is documented anywhere in the language reference. Not in stdtypes, not in datamodel, not in expressions, not in the C API’s long documentation. That absence is not an oversight. It is the specification declining to promise you anything, and it means the behaviour is free to change between versions, between implementations, and between compilation contexts within one version — as the example above shows it already does.
💡A test asserts assert parse("1.0.0") is not parse("1.0.0") to prove that parse does not cache. Why is that test bad even though it currently passes?
click to reveal
Because it tests the absence of an optimisation rather than a behaviour anyone depends on. If someone later adds memoisation to parse — a perfectly reasonable change for a hot path — the test fails, and it fails for a reason that has nothing to do with whether the program is correct. It is a test that only ever generates false alarms.
Worse, it teaches the reader that identity is part of parse‘s contract, which invites someone to rely on it elsewhere.
What the test presumably wanted to assert is that two independently parsed equal versions compare equal and behave interchangeably — parse("1.0.0") == parse("1.0.0"), that they hash the same, that a set of them has one element. Those are properties of the value type, they are what callers actually use, and they stay true whether or not parse caches.
__eq__ and NotImplemented
When a == b cannot be answered by a.__eq__(b), the correct return value is NotImplemented, not False:
def __eq__(self, other: object) -> bool:
if not isinstance(other, Version):
return NotImplemented # mypy accepts this despite the -> bool
return (self.major, self.minor) == (other.major, other.minor)
Returning NotImplemented tells Python to try the reflected operation, b.__eq__(a). If that also declines, Python falls back to identity comparison, which gives False. Returning False directly short-circuits that, so a class that does know how to compare itself to yours never gets asked — which is how a Decimal-like or proxy type ends up unequal to something it should equal.
The signature is (self, other: object) -> bool, with object and not your own type, because == can be called with anything. dataclass generates all of this correctly, which is one of several reasons to let it.
And the contract that goes with it: if you define __eq__, you must define __hash__ consistently. Objects that compare equal must hash equal, or they will both appear in a set and a dict lookup will miss. Defining __eq__ in a plain class sets __hash__ = None, making instances unhashable — Python’s way of forcing you to make the decision. @dataclass(frozen=True) generates a __hash__ from the compared fields.
field(compare=False) and what identity means for your type
order=True on a dataclass generates comparisons over every field, as a tuple, in declaration order. Sometimes a field is metadata rather than identity — a build label, a source filename, a timestamp of when the record was loaded. field(compare=False) excludes it from __eq__, from ordering, and from the generated __hash__.
That is a real modelling decision, not a formatting one. Excluding a field says: two values that differ only here are the same value. They will collapse in a set, they will collide as dict keys, and one of them will win. If that is not what you mean, the field belongs in the comparison.
--strict-equality, which you already have
mypy --strict includes --strict-equality, which rejects comparisons between types whose values can never overlap:
if version == "1.0.0": # error: non-overlapping equality check
...
This catches a genuine category of bug — comparing a parsed value against its unparsed form, comparing an enum member against its .value, comparing bytes against str — that is otherwise silent, because == between unrelated types is not an error at runtime, it is just always False.
💡Version(1, 0, 0) == object() must return False and must not raise. Given the @dataclass-generated __eq__ returns NotImplemented for a non-Version, trace what Python actually does to arrive at False.
click to reveal
Python evaluates a == b by trying type(a).__eq__(a, b) first. The dataclass-generated __eq__ checks other.__class__ is self.__class__ and, since object is not Version, returns NotImplemented.
NotImplemented is a signal, not an answer, so Python tries the reflected operation: type(b).__eq__(b, a), which is object.__eq__. The default object.__eq__ compares identity and returns NotImplemented for anything that is not the same object — it declines rather than answering False, precisely so that a subclass on the other side gets its turn.
Both sides having declined, Python falls back to its last resort for ==: identity. The two objects are not the same object, so the result is False. No exception anywhere, because at no point did anything try to read an attribute off object().
The reason this matters practically: if the dataclass had returned False instead of NotImplemented, a hypothetical VersionProxy class that does know how to compare itself against Version would never be asked, and Version(1,0,0) == VersionProxy("1.0.0") would be False while the reversed comparison was True. Asymmetric equality is a genuinely nasty bug, and NotImplemented is the mechanism that prevents it.