"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.
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_cacheon a function that took aMoneyargument (the cache key includes arguments);set(prices)in a report to find distinct price points;- a dictionary keyed by
Moneyused 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.
Decimalis 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.NamedTuplegives 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
- Python data model, object.hash: https://docs.python.org/3/reference/datamodel.html#object.__hash__
- Python dataclasses, frozen and unsafe_hash: https://docs.python.org/3/library/dataclasses.html
- Python glossary, hashable: https://docs.python.org/3/glossary.html#term-hashable
- functools.lru_cache: https://docs.python.org/3/library/functools.html#functools.lru_cache
Add a label
Embed this post
<iframe src="https://www.hibug.me/python-eq-without-hash-unhashable-type" width="700" height="420" frameborder="0" loading="lazy"></iframe>
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.
Related Stories
pandas cleaned values never saved: SettingWithCopyWarning and chained assignment
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.