Python Identity versus Equality — the instance that is not a name
A name is not the object. A name is a separate thing.
<!-- hal:authoritative:yaml -->
A name is not the object. A name is a separate thing.
§I — Frame
Ramalho opens Chapter 6 with Alice and the Knight. The name of the song is called one thing. The name really is another. Then the sentence this lesson spends: a name is not the object; a name is a separate thing (Fluent Python, 2nd ed., Ch.6, "Object References, Mutability, and Recycling").
Today's Ops lesson holds last night's MIG member name and loses the member. Autoheal mints a new VM. The last path segment can come back. A cache keyed by that string treats the new machine as the old one. The language form of that bug is == where is was required, or a custom __eq__ that compares a label the world is allowed to recycle.
is tests identity. == tests equality. The __eq__ inherited from object compares ids, so it agrees with is. Most built-in types override __eq__ and look at values. Ramalho's rule of thumb is the same as Lutz's: you usually want ==; is is for specialized roles, of which is None is the common one (Lutz, "Comparisons, Equality, and Truth"; Ramalho, identity versus equality).
Default class equality is identity. If you have not written __eq__, two seats with the same name field are not equal. If you write __eq__ on name alone, two seats that shared a recycled name are equal, and the hash contract will demand they hash the same. That is the cache that lies.
Coin the same name the Ops slot coined: the instance that is not a name.
This is not 08-17. That lesson was weakref.finalize and the destructor you cannot schedule. Today the object is still alive. The question is whether another object with the same label is the same object. It is not 08-14 match/case, not 08-08 descriptors, not 08-02 Protocols.
§II — Foundations: five facts about the name and the object
**Fact one. is is a pointer test. == is a value test.**
Lutz's heading is the lab: "Shared References and Equality." Two lists built from two literals:
L = [1, 2, 3]
M = [1, 2, 3]
L == M
L is M
== is true. is is false. is compares the pointers that implement references. It is how you detect a shared reference. It is false when the names point at equivalent but different objects (Lutz, same heading).
Small integers and small strings break the intuition because CPython caches them. Lutz writes X = 42; Y = 42 and X is Y comes back true. A longer string pair ('a longer string' twice) restores the normal picture: == true, is false ("Comparisons, Equality, and Truth"). The cache is an implementation fact, not a license to use is for value.
The operator habit is if flag is True. That habit treats a cached singleton as a value test and then fails on a numpy bool. == is the value test. is is the identity test. Lutz: as a rule of thumb, == is what you want for almost all equality checks; is is reserved for highly specialized roles.
**Fact two. Inherited __eq__ is identity. Most types override it.**
Ramalho: a == b is syntactic sugar for a.__eq__(b). The method inherited from object compares object ids, so it produces the same result as is. Built-in types override it and walk values. Equality can be expensive. Identity is two integers.
So a user class with no __eq__ treats two separately constructed instances as unequal even when every field matches. That is correct for a MIG member you meant to track as an object. It surprises anyone who thought Seat(name="web-instance-abcd") was a value.
class Seat:
def __init__(self, name: str) -> None:
self.name = name
a = Seat("web-instance-abcd")
b = Seat("web-instance-abcd")
a == b
a is b
Both false, except you will write a == b and stare. There is no custom __eq__. Identity lost. The names match. The instances do not.
**Fact three. If you define __eq__, you own __hash__.**
"What Is Hashable" (Ramalho, p.84, adapted from the glossary): an object is hashable if it has a hash code that never changes during its lifetime (__hash__) and can be compared (__eq__). Hashable objects which compare equal must have the same hash.
User-defined types are hashable by default because their hash code is id() and inherited __eq__ compares ids (Ramalho, same section). The moment you write a value __eq__, the inherited __hash__ is a lie: two equal objects can have different ids, hence different hashes, and a dict will store them as two keys. CPython then sets __hash__ to None if you define __eq__ and do not define __hash__, and the instance becomes unhashable. That is the safer default. It is not a cache.
The contract, in one line: if a == b then hash(a) == hash(b). The converse is not required. Hash collisions are normal. Identity of hashes is not identity of objects.
Lutz on comparisons: there are no implicit relationships among the operators. The truth of == does not imply that != is false; define both __eq__ and __ne__ if you define one (Lutz, comparison-method notes). Python 3 will derive __ne__ from __eq__ if you skip it, but the book is warning you not to assume a lattice.
**Fact four. Dataclass eq, frozen, and eq=False are three different objects.**
Ramalho's @dataclass signature includes eq=True, frozen=False, unsafe_hash=False (Ch.5, "More About @dataclass"). Defaults generate __eq__ from the fields. They do not generate __hash__. If frozen=False (the default), the decorator sets __hash__ to None so the instances are unhashable. If eq and frozen are both true, it produces a __hash__ from the fields that participate.
eq=False turns the generated equality off. The class falls back to identity. That is the right shape when the fields include a label the world can recycle and you still want two constructions to be two objects.
frozen=True without thinking is not a hash strategy. It is a mutation fence. A frozen dataclass whose only field is name: str will hash by name. Two seats minted at different generations then collide in a set. Frozen and keyed by name is the Ops cache, dressed as a type.
Fact five. A recycled name is an equality trap, not an identity.
The Ops last segment is a string. Strings compare by value. 'web-instance-abcd' == 'web-instance-abcd' is true across processes, across nights, across autoheals. The member is not. If your in-memory object holds only the name, you have a label, not an instance. Key by a generation the API minted (instance id, self-link, a monotonic generation you assigned when you first saw the self-link). Or keep the object and test is.
WeakValueDictionary is optional furniture, not the topic. It maps a key to an object without keeping the object alive (the 08-17 clock, reused as a cache, not as a finalizer). If the seat is collected, the entry vanishes. It does not fix a key that was the name. A weak cache keyed by name still revives the lie the next time you insert a new seat under the old string.
§III — Worked example: a seat cache that refuses the leftover name
The Ops tool listed members and wanted to remember them. The wrong map is dict[str, Seat]. The right map is a key the autoheal cannot reprint.
from dataclasses import dataclass
from weakref import WeakValueDictionary
@dataclass(eq=False)
class Seat:
group: str
generation: int
name: str
@dataclass(frozen=True)
class SeatKey:
group: str
generation: int
class NameCache:
def __init__(self) -> None:
self._by_name: dict[str, Seat] = {}
def remember(self, seat: Seat) -> None:
self._by_name[seat.name] = seat
def get(self, name: str) -> Seat | None:
return self._by_name.get(name)
class GenerationCache:
def __init__(self) -> None:
self._by_key: dict[SeatKey, Seat] = {}
self._live: WeakValueDictionary[SeatKey, Seat] = WeakValueDictionary()
def remember(self, seat: Seat) -> None:
key = SeatKey(seat.group, seat.generation)
self._by_key[key] = seat
self._live[key] = seat
def get(self, group: str, generation: int) -> Seat | None:
return self._by_key.get(SeatKey(group, generation))
Seat is eq=False. Two constructions with the same name are not equal. SeatKey is frozen and holds (group, generation). Those two fields are the identity the group minted, not the label. NameCache.get("web-instance-abcd") after a recycle returns the new seat or the old one, and you cannot tell. GenerationCache.get cannot be called with a name. That is the point.
a is b on two Seat objects you pulled from GenerationCache with the same key is a question about whether you stored one object. a == b on eq=False seats is the same question. a.name == b.name is the question that lies.
A value-equal seat you did want would be frozen on (group, generation) and would leave name out of __eq__ and __hash__. Field-level compare=False on name is the dataclass spelling. Do not hash a field you already called a label.
§IV — Failure mode: equal names, different instances
The common bug is a one-field __eq__.
@dataclass(frozen=True)
class NamedSeat:
name: str
generation: int = 0
The generated __eq__ and __hash__ include generation, so this particular spelling happens to survive a recycle if you remember to bump generation. Someone then constructs NamedSeat("web-instance-abcd") twice with generation=0, and the set collapses them. Frozen made it hashable. The fields made it a name.
A worse spelling:
class NamedSeat:
def __init__(self, name: str) -> None:
self.name = name
def __eq__(self, other: object) -> bool:
if not isinstance(other, NamedSeat):
return NotImplemented
return self.name == other.name
No __hash__. The instances are unhashable. Someone then writes __hash__ = object.__hash__ to "put them in a dict," and equal names hash differently because object.__hash__ is id(). The dict accepts both. Lookup by a third NamedSeat("web-instance-abcd") misses both. The contract is broken in public.
is used as a value test on strings is the Lutz small-int trap in ops clothes:
if member.last_segment is remembered:
...
It works until interned-or-not decides it does not. == is the string test. is is for the Seat object you still hold.
WeakValueDictionary keyed by name looks like it solved leaks. It did not solve identity. When the old seat dies, the weak entry dies. The next remember(new_seat) inserts under the same name. The weak cache is now the NameCache. Furniture does not change the key.
The tell is a unit test that constructs two seats with the same name and asserts a == b. That test is documenting the lie. Assert a.name == b.name if you mean the label. Assert a is b if you mean the instance. Assert SeatKey(a.group, a.generation) == SeatKey(b.group, b.generation) if you mean the group-minted identity.
§V — Pairing
Today's Ops lesson is the census. list_managed_instances returns self-links and last segments. The last segment is NamedSeat.name. The self-link (or the instance id inside it) is SeatKey.generation. A leftover-name check that uses == on the segment is a label test and is allowed. A health cache that uses the segment as a dict key is this lesson's NameCache.
Today's Cert lesson is PCA on the Ch.2 chain. The instance template is the type. MIG members are disposable. Autohealing health check is not the load-balancer health check. The exam will hand you a VM name. The answer is the group. Same coin, three altitudes: the instance that is not a name.
08-17 stays closed. finalize runs after the object dies. Today the object is alive and a second object wears its label. 08-14, 08-08, and 08-02 are other days.
§VI — Drills
Seat has no __eq__. a = Seat("web-instance-abcd"); b = Seat("web-instance-abcd"). What is a == b, what is a is b, and which book sentence decides the first one?__eq__ inherited from object compares object ids, so it produces the same result as is. The matching name is not the instance.__eq__ that compares self.name only and leave __hash__ undefined. Can you put the objects in a set? If you set __hash__ = object.__hash__, what contract breaks when two names match?__hash__ becomes None). Forcing object.__hash__ lets equal objects have different hashes, so a set can hold both and a lookup by a third equal name can miss.WeakValueDictionary is keyed by name. Autoheal reuses web-instance-abcd. The old Seat was collected. You remember the new seat. What does cache["web-instance-abcd"] return, and what key refuses the collision?(group, generation) or by self-link, not by name.Related
- Prior arc: the signal that must still fire (2026-08-17)
- Domain hub: Cross-References/domains/Polyglot-Dev
- Grounding tome: Fluent Python, 2nd ed. (Ch.6 identity versus equality; What Is Hashable p.84; @dataclass eq/frozen) · Learning Python (Lutz) (Shared References and Equality; Comparisons, Equality, and Truth)
🫡 ⚖️ 📜 Leo.Syri — Praetor Consulate, Imperium Luminaura Filed 2026-08-20 · Fajr · sprint track Python day 29 · tenth Python visit · trio #95