Programming Languages Bug Report

"TypeError: unhashable type" after adding __eq__ to a Python class: __hash__ became None

A small refactor added __eq__ to our Money class so amounts could be compared. Soon a cache decorator and several set operations failed with TypeError: unhashable type: 'Money', because defining __eq__ without __hash__ sets __hash__ to None. How equality and hashing must agree.

Lucas Moreau Lucas Moreau @lucasmoreau · July 14, 2025 · 7 min read · 23 Views
Banner: TypeError unhashable type after adding __eq__ to a Python class because __hash__ became None

Our pricing module has a small Money class. A developer added an equality method so tests could compare amounts with ==, instead of comparing .amount and .currency separately. The pull request was tiny, the tests passed, and it was merged. In staging, the quote endpoint failed:

Traceback (most recent call last):
  File "/app/pricing/quotes.py", line 88, in quote
    rate = fx_rate(price.currency, target)          # cached
  File "/usr/local/lib/python3.12/functools.py", line ..., in wrapper
TypeError: unhashable type: 'Money'

And a batch job failed in a different place:

  File "/app/pricing/reports.py", line 41, in unique_prices
    return set(prices)
TypeError: unhashable type: 'Money'

The change

class Money:
    def __init__(self, amount: Decimal, currency: str):
        self.amount = amount
        self.currency = currency

    def __eq__(self, other):
        if not isinstance(other, Money):
            return NotImplemented
        return self.amount == other.amount and self.currency == other.currency

Before the change, Money objects were hashable, because every class inherits object.__hash__, which hashes by identity. After the change:

>>> Money.__hash__ is None
True
>>> hash(Money(Decimal("1.00"), "EUR"))
TypeError: unhashable type: 'Money'

Where hashing was used

Searching for places where Money objects ended up as dictionary keys or set members found three:

  • functools.lru_cache on a function that took a Money argument (the cache key includes arguments);
  • set(prices) in a report to find distinct price points;
  • a dictionary keyed by Money used to group order lines by unit price.

The tests for the pull request covered only arithmetic and formatting.

What we ruled out

  • A Python version change. The behaviour is long-standing language semantics, not new in 3.12.
  • Decimal being unhashable. Decimal is hashable; the problem was the class itself.

Root cause

When a class defines __eq__ but not __hash__, Python sets __hash__ to None, making instances unhashable. This prevents a broken combination: objects that are equal but would hash differently under the inherited identity-based hash. Code that used Money instances as dictionary keys, set members or cache arguments started failing.

The equality and hashing contract

Python's data model requires that objects which compare equal have the same hash. Dictionaries and sets rely on it: they find a bucket by hash, then confirm with ==. If two equal objects had different hashes, a dictionary could hold both as separate keys, and lookups would fail unpredictably.

The inherited object.__hash__ is based on identity. It is consistent with the inherited object.__eq__, which is also identity. Overriding __eq__ breaks that consistency, so Python removes the hash rather than leave a broken pair in place.

Defines __eq__ Defines __hash__ Result
No No Identity equality and identity hash
Yes No __hash__ is set to None: unhashable
Yes Yes Your hash; must agree with your equality
No Yes Unusual; hash should still agree with identity equality

The mutability trap

A hash must not change while an object is in a set or used as a key. If Money is mutable and you hash its fields, changing amount after inserting it into a set leaves it in the wrong bucket. So hashable value objects should be immutable.

Who is affected

Any class that gains an __eq__ method after it has been used as a dictionary key, set member or cache argument: value objects, configuration classes and ORM-like records. Dataclasses behave differently by default: @dataclass with eq=True (the default) and frozen=False also sets __hash__ to None, which surprises people who expect dataclasses to be hashable. Subclasses inherit the None hash too.

Environment

Component Version / value
Python 3.12
Class Money value object, mutable attributes
Hash users functools.lru_cache, set(), dict grouping
Tests Unit tests for arithmetic only

Make Money an immutable value object

A frozen dataclass generates __eq__, __hash__ (consistent with equality) and __repr__, and prevents attribute assignment after creation:

from dataclasses import dataclass
from decimal import Decimal

@dataclass(frozen=True, slots=True)
class Money:
    amount: Decimal
    currency: str

    def __post_init__(self):
        if not isinstance(self.amount, Decimal):
            object.__setattr__(self, "amount", Decimal(self.amount))
        object.__setattr__(self, "currency", self.currency.upper())

    def __add__(self, other: "Money") -> "Money":
        if other.currency != self.currency:
            raise ValueError("currency mismatch")
        return Money(self.amount + other.amount, self.currency)
>>> a = Money(Decimal("1.00"), "eur"); b = Money(Decimal("1.00"), "EUR")
>>> a == b, hash(a) == hash(b)
(True, True)
>>> a.amount = Decimal("2")
dataclasses.FrozenInstanceError: cannot assign to field 'amount'

__post_init__ uses object.__setattr__ because normal assignment is blocked on frozen instances; it normalises values once, at creation, so equal amounts compare and hash the same.

A subtle point about Decimal equality

Decimal("1.0") == Decimal("1.00") is True, and their hashes are equal too, so Money(Decimal("1.0"), "EUR") and Money(Decimal("1.00"), "EUR") are equal and hash the same. That matched our business meaning. If your domain treats them differently (for example, precision matters), normalise or quantise in __post_init__ and document it.

If the class must stay mutable

Then it should not be hashable. Keep __hash__ = None and change the call sites: cache on (amount, currency) tuples, and group by a tuple key instead of the object.

Alternatives and trade-offs

  • @dataclass(eq=True, unsafe_hash=True) generates a hash for a mutable class. The name is a warning: mutation after hashing breaks sets and dicts.
  • typing.NamedTuple gives immutability and hashing too, but tuple semantics (indexing, equality with plain tuples) are not always desirable for domain types.

Comparing with other types

The original __eq__ returned NotImplemented for non-Money objects, which is correct: Python then tries the reflected comparison and finally falls back to identity, so Money(...) == 5 is False instead of raising. Dataclasses do the same, comparing only instances of the same class. Avoid returning False directly for unknown types in hand-written __eq__ methods, because it prevents the other operand from providing its own comparison.

How to test the fix

def test_money_is_hashable_and_consistent():
    a = Money(Decimal("10.00"), "EUR")
    b = Money(Decimal("10.0"), "eur")
    assert a == b and hash(a) == hash(b)
    assert len({a, b}) == 1
    assert {a: "x"}[b] == "x"

def test_money_is_immutable():
    m = Money(Decimal("1"), "EUR")
    with pytest.raises(FrozenInstanceError):
        m.amount = Decimal("2")

We also added a test calling the cached fx_rate function with Money arguments, the path that failed.

Confirming in production

The quote endpoint and the report job ran normally after the release. The lru_cache hit rate on fx_rate returned to its previous level, about 97%, confirming equal amounts mapped to the same cache entries.

Tests that would have caught it

The original pull request had tests for equality but none that put a Money object into a set or a dictionary. A value object's tests should cover how it is used, not only its methods. We added a shared test helper that every value object's test module calls, which checks equality, hashing, immutability and repr in one place.

Regression guard

  • A unit test suite for value objects asserts hashability, equality consistency and immutability.
  • A lint rule flags classes that define __eq__ without __hash__ (Pylint has a check for this), so the decision is explicit.

Security implications

None directly. Inconsistent equality and hashing in security-relevant code, such as permission sets or token caches, can cause lookups to miss and checks to behave unpredictably, so value objects used in those places deserve the same tests.

Prevention checklist

  • Defining __eq__ makes a class unhashable unless you also define __hash__.
  • Equal objects must have equal hashes; hash only immutable fields.
  • Use @dataclass(frozen=True) for value objects.
  • Normalise field values at creation so equality matches business meaning.
  • Test value objects in sets, dicts and caches, not only arithmetic.

References

Add a label

#python #dataclasses #hashing #equality #data-model
Written by
Lucas Moreau @lucasmoreau

I build backend services in Go, Node.js and Python, mostly APIs and workers behind high-traffic products. Over the years I have become the person who gets the "it only fails under load" tickets. Those bugs are almost always about concurrency or resource limits: goroutines that never exit, a blocked event loop, a connection limit nobody knew existed. I write each one up with a small program that reproduces it, an explanation of the runtime behaviour behind it, the fix and the test or metric that proves it, so the next engineer does not need a load test to find it.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.