skip to content

Why does mutating an object already used as a `dict` key make later lookups miss it?

level: seniorimportance: should knowfreq 46%

answer

  1. The number was taken once, at the door
  2. Present by iteration, absent by lookup
  3. Equality never gets a turn
  4. discard() is the quietest failure
  5. Immutable keys remove the possibility

basics

~20 s

The container placed the key using the hash it had at insertion. Mutating a hashed field changes the hash, so lookups search the wrong place: the key tests as absent while iteration still yields it.

solid answer

~50 s

A `dict` or `set` computes the key's hash **once, at insertion**, and uses it to decide where the entry lives. Mutating a field that `__hash__` reads changes the value the container would compute now, so a later lookup goes somewhere else entirely and the equality check is never reached. The result is a genuinely inconsistent container: `key in d` is `False`, `d[key]` raises `KeyError` and `set.discard(obj)` silently removes nothing, yet `len()` still counts the entry and iteration still yields the object. You can even end up with two keys in one `dict` that compare equal to each other. Nothing raises, because hashability is a promise the class makes once and Python never re-checks it. The fixes are structural: make key objects immutable, hash only fields that never change, or key the container by an extracted immutable value.

code

python · 23 lines
python
class Service:
    def __init__(self, name, region):
        self.name, self.region = name, region

    def __eq__(self, other):
        if not isinstance(other, Service):
            return NotImplemented
        return (self.name, self.region) == (other.name, other.region)

    def __hash__(self):
        return hash((self.name, self.region))


svc = Service("billing", "eu")
handles = {svc: "handle-1"}

svc.region = "us"                          # a hashed field changes in place
print(svc in handles)                      # False
print(len(handles))                        # 1
print([s.name for s in handles])           # ['billing'] - still stored

svc.region = "eu"                          # restore it
print(svc in handles)                      # True again

go deeper

for a junior

Know the rule of thumb before the mechanism: only immutable things belong in a set or as a dict key, which is why a list cannot be one and a tuple can.

for a middle

Explain that the hash is computed at insertion and again at lookup, and that a changed field makes the two disagree so the equality check is never reached. Name the symptoms: membership False, iteration still yielding the object.

for a senior

Diagnose it from the outside — an entry that iteration shows and in denies, a discard that removes nothing, an unclosed resource — and choose a structural fix such as immutable keys, hashing a stable id, or keying by an extracted value.

for a principal

Own the boundary between value objects and entities in the domain model, so mutable entities never carry a field-based hash at all and the failure mode cannot enter the codebase through a well-meaning __eq__.

## What the container remembers When you insert into a `dict` or a `set`, the container asks the key for its hash exactly once and uses that number to decide where the entry goes. It stores the key object, not a promise to re-derive its position later. A lookup repeats the same procedure: compute `hash(probe)`, go to the place that number points at, and compare candidates found there with `==`. That design is what makes lookups fast, and it is also what makes mutation catastrophic. If the field that `__hash__` reads changes after insertion, the number the container computed at insertion and the number it computes at lookup no longer agree. The lookup goes to the wrong place, finds nothing, and reports absence — `__eq__` is never even called, so a perfectly correct equality implementation cannot save you. ## The symptoms, which are the interesting part The container does not become empty or corrupt in a way that crashes. It becomes **inconsistent**, and every symptom points somewhere unhelpful: - `obj in d` and `obj in s` return `False`. - `d[obj]` raises `KeyError`, and `d.pop(obj)` and `set.remove(obj)` do too. - `set.discard(obj)` returns silently, having removed nothing — the quietest failure of the set. - `len(d)` is unchanged and iteration still yields the entry, so a dump of the container shows the "missing" key sitting right there. - Re-inserting the object adds a *second* entry, so the container can hold two keys that compare equal to each other. - Restoring the field to its original value makes the key findable again, which turns the bug into an intermittent one and destroys most attempts to reproduce it. ## Where it actually happens A concrete shape: a subscription-billing run walks a 17-service dependency graph and holds one open handle per service in a `dict` keyed by a `Service` object, so it can close each handle when the service's charges are settled. `Service` is a value object — its `__eq__` and `__hash__` cover `(name, region)`. Partway through, a retry path reassigns `service.region` to a failover region on the same object that is already a key. From that moment the handle for that service is unreachable. `handles.pop(service)` raises `KeyError`, or — worse, if the cleanup was written defensively with a `discard`-style call — nothing happens at all and no error is logged. The run finishes "successfully" with a resource left unclosed, and the only downstream evidence is a slow leak of connections that shows up hours later on an unrelated service in the graph. Nothing in the traceback, if there even is one, mentions `__hash__`. ## Why Python permits it Hashability is a **class-level** promise, made when the class defines a `__hash__`, and the interpreter has no way to re-verify it per instance. Nothing observes attribute writes on your behalf, and re-hashing every key on every lookup would defeat the entire point of a hash-based container. So Python enforces the one thing it can — it removes the inherited `__hash__` when you define `__eq__` — and trusts you for the rest. Contrast the good failure mode: a `list` is unhashable, so putting one in a `set` raises `TypeError` immediately, at the moment of the mistake rather than an hour later. ## Fixing it properly Every real fix removes the possibility rather than documenting it. **Make key types immutable.** Fields set once in `__init__` and never reassigned; better, enforce it by rejecting attribute writes after construction so a stray assignment raises instead of corrupting a container. A `tuple` or a `frozenset` used directly as the key gets you this for free. **Hash only the stable identity.** If an object has a natural immutable identifier — a primary key, an assigned id — let `__eq__` and `__hash__` cover only that, and let the mutable attributes live outside the contract. This is usually the right model for long-lived domain entities, which are exactly the objects people mutate. **Key by an extracted value.** Rather than storing the object as the key, key the mapping by `service.name` or by the `(name, region)` tuple and store the object as the value. The container then depends on a snapshot, not on an object someone else may still hold a reference to and mutate. **Keep the default identity hash.** For entity objects that are mutated by design, not overriding `__eq__` at all leaves identity equality and identity hashing in place — and identity cannot change, so the key can never go missing however much state you rewrite. ## Detecting it There is no runtime warning to enable, so detection is design-time. Treat any hand-written `__hash__` as a claim that the fields it reads are frozen, and check the claim in review. A cheap regression test is to insert an instance, mutate a hashed field, and assert the container still finds it — a test that *fails loudly today* documents exactly which fields the contract depends on. When triaging an existing mystery, the tell is the combination that should be impossible: an object that iteration yields but membership denies.

  • Why does the container still report the entry in `len()` and iteration?
    Because the entry was never removed. Iteration walks the stored entries directly and never consults a hash, so it yields the object; `len()` counts the same entries. Only lookup, membership and removal go through the hash, and those are precisely the operations that now compute a different number and search the wrong place. That contradiction — visible in a dump, invisible to `in` — is the diagnostic tell.
  • Can a single `dict` end up with two keys that compare equal to each other?
    Yes. Once the first key's hash no longer matches its stored position, inserting an equal object hashes to the new position, finds no match there, and is added as a separate entry. The dict then holds two keys for which `==` is `True`, which no correct usage can produce. It is a strong signal that a key was mutated after insertion.
  • How would you design a mutable domain entity that still needs to go in a `set`?
    Base `__eq__` and `__hash__` on an immutable identifier assigned at construction — an id or primary key — and leave the mutable attributes out of both. Alternatively keep the inherited identity equality and hashing, which can never drift. Either way the contract covers only fields that are frozen for the object's lifetime, so mutation of business state cannot move a stored key.

It is a library that shelves a book by its title and then lets someone retitle the spine. The book is still on the shelf and still in the catalogue count, but every search walks to the wrong aisle and reports it missing.

saying these in an interview costs you the question

  • Says the container rehashes keys automatically
  • Claims mutating a key raises an error
  • Thinks the entry is deleted or the dict corrupted
  • Expects `set.discard` to report the failure
  • Blames `__eq__` rather than the changed hash
  • Proposes documenting the rule instead of freezing the key

context