Create your own
Lesson illustration

Implementing Hashing Consistent with Equality

Hello again. In the previous lesson, you defined equality for the capstone’s mutable Job entity: two jobs are equal when they have the same durable job_id, regardless of changing fields such as status or retries.

This lesson completes that contract. You will implement __hash__ so equal jobs can be used correctly in sets and as dictionary keys, while preserving the key safety requirement: the data that determines a hash must not change during the object’s lifetime.


Hashing is the fast-routing layer for sets and dictionaries

A hash is an integer that Python uses to narrow down where an object may be stored or found in a hash-based collection:

  • set
  • frozenset
  • dictionary keys

Conceptually, when Python evaluates job in jobs or jobs_by_id[job], it first computes hash(job). It uses that number to locate a small candidate area, then uses equality to determine whether a candidate is actually the object being sought.

The diagram shows that different keys can produce the same hash value. A collision is normal; equality is what distinguishes candidates after hashing.

This gives the central rule:

The reverse is not required:

Different objects may collide, as in the image. A collision can make a lookup do extra equality checks, but it does not make a set or dictionary incorrect. The dangerous situation is the other direction: equal objects with different hashes. Python may search in the wrong candidate area and fail to find an object that is logically present.

The official language reference is the primary source for this contract.

3. Data model — Python 3.14.2 documentation

Read the official Python Language Reference section on object.__hash__. It defines the equality–hash contract and explains why Python makes a class unhashable after it defines equality without defining a compatible hash.

In the object.__hash__ section, read the core contract first. Then continue through the rest of the section, especially the paragraphs beginning “If a class does not define”, “A class that overrides”, and “If a class defines mutable objects”. Focus on the distinction between a mutable object in general and an object whose equality and hash inputs can change.


Why your Job became unhashable

Before you wrote Job.__eq__, the class inherited identity-based equality and identity-compatible hashing from object. Two distinct instances were unequal, and could therefore safely have unrelated hashes.

Once you defined value-based equality, Python deliberately changed the default:

class Job:
    def __eq__(self, other: object) -> bool:
        ...

Without a __hash__ method, Python effectively sets:

Job.__hash__ = None

Consequently:

job = make_job(job_id=UUID(int=1))

hash(job)

raises:

TypeError: unhashable type: 'Job'

And this is also rejected:

jobs = {job}

This is a protective default, not an inconvenience. Suppose Python had retained an identity-based hash while your __eq__ compared job_id values. Two independently loaded Job objects could compare equal but receive unrelated identity-based hashes. A set could then retain both, or a dictionary lookup could fail unexpectedly.

The language’s decision is conservative: if you customize equality, explicitly decide whether the object can be hashable.


Derive the hash from exactly the equality key

Recall the capstone’s equality definition:

Two Job objects are equal exactly when they have the same job_id.

The correct implementation follows directly:

def __hash__(self) -> int:
    return hash(self.job_id)

UUID instances are hashable, so hash(self.job_id) returns an integer. More importantly, if two jobs have equal UUIDs, calling hash() on those UUIDs produces equal hashes.

Here is the complete relevant part of Job:

from uuid import UUID

from .retry_count import RetryCount


class Job:
    def __init__(
        self,
        job_id: UUID,
        owner_id: UUID,
        job_type: str,
        retries: RetryCount,
        status: str,
    ) -> None:
        self._job_id = job_id
        self.owner_id = owner_id
        self.job_type = job_type
        self.retries = retries
        self.status = status

    @property
    def job_id(self) -> UUID:
        return self._job_id

    def __eq__(self, other: object) -> bool:
        if self is other:
            return True

        if type(other) is not type(self):
            return NotImplemented

        assert isinstance(other, Job)
        return self.job_id == other.job_id

    def __hash__(self) -> int:
        return hash(self.job_id)

The assert isinstance(other, Job) is there to make the runtime narrowing explicit to a static type checker. The exact-type check already establishes the necessary runtime condition for this class’s equality policy.

For an object with multiple equality-defining fields, use a tuple:

def __hash__(self) -> int:
    return hash((self.account_id, self.date))

Tuples provide a clean way to combine multiple hashable components while preserving their order and boundaries. But Job has one equality-defining field, so hashing job_id directly is clearer.

A useful implementation rule is:

Every field used in __eq__ must be represented in __hash__, and no field used in __hash__ may be mutable over the object’s lifetime.

The second half is particularly important. Your hash may be less selective than equality in a mathematical sense, but it must still ensure that equality always implies equal hashes. In normal application design, matching the equality components precisely is the clearest and most maintainable approach.


Mutable entities can be hashable, but their identity must be stable

It would be too broad to say “mutable objects must never be hashable.” What matters is whether the values that determine equality and hashing can change.

For Job:

FieldMay change during processing?Used in equality?Used in hash?
job_idNoYesYes
owner_idUsually no, but not identityNoNo
job_typeNo practical need to changeNoNo
retriesYesNoNo
statusYesNoNo

Changing status is safe with respect to hashing:

job = make_job(job_id=UUID(int=1), status="queued")
jobs = {job}

job.status = "running"

assert job in jobs

The job remains in the same hash location because its job_id did not change.

Changing job_id, by contrast, would corrupt the collection’s lookup assumptions:

job = make_job(job_id=UUID(int=1))
jobs = {job}

# Do not permit this in the domain model.
job.job_id = UUID(int=2)

The property-based design above deliberately has no setter, so reassignment through the public API fails:

job.job_id = UUID(int=2)
# AttributeError: property 'job_id' of 'Job' object has no setter

The private _job_id naming convention is still only a convention in Python. Code inside the domain layer must treat it as immutable after construction. Later, when you evaluate __slots__, you will have additional tools for constraining instance layout, but the fundamental domain rule remains the same: an entity’s durable identity is not reassigned.

This short video provides a compact walkthrough of the tuple-hash pattern and the role of equality during set insertion.

python: what is hashability? (intermediate) anthony explains #242

Watch “python: what is hashability? (intermediate) anthony explains #242” by anthonywritescode for a concise implementation-level view of __hash__ and __eq__ working together.

Watch the implementation example. Focus on the choice to hash immutable state and on why a set still uses equality after it finds hash candidates.


What not to hash

The following implementation is wrong for the capstone’s entity semantics:

def __hash__(self) -> int:
    return hash((self.job_id, self.status, self.retries))

It includes fields that equality deliberately ignores. Two jobs can represent the same entity while having different statuses:

queued = make_job(
    job_id=UUID(int=1),
    status="queued",
    retries=0,
)

running = make_job(
    job_id=UUID(int=1),
    status="running",
    retries=1,
)

assert queued == running

The bad hash implementation is not guaranteed to give these objects the same hash, so it fails the contract. It also creates a second danger: if the same job changes status after being inserted into a set, its hash can change and make it difficult or impossible to retrieve through normal lookup.

Do not attempt to solve this by returning a constant hash:

def __hash__(self) -> int:
    return 0

This technically preserves the required implication because every object has the same hash. However, it forces dictionaries and sets to perform many equality checks, degrading the performance that hash-based collections are meant to provide. It is a useful thought experiment, not a production design.

Likewise, do not restore identity hashing:

__hash__ = object.__hash__

That would conflict with equality based on job_id: separate Job instances with the same ID would compare equal but generally hash differently.


Verify the contract through collection behavior

Add focused tests alongside the equality tests from the previous lesson.

from uuid import UUID

from app.domain.job import Job
from app.domain.retry_count import RetryCount


def make_job(
    *,
    job_id: UUID,
    status: str = "queued",
    retries: int = 0,
) -> Job:
    return Job(
        job_id=job_id,
        owner_id=UUID(int=10),
        job_type="thumbnail",
        retries=RetryCount(retries),
        status=status,
    )

First, test the direct law:

def test_equal_jobs_have_equal_hashes() -> None:
    queued = make_job(
        job_id=UUID(int=1),
        status="queued",
        retries=0,
    )
    running = make_job(
        job_id=UUID(int=1),
        status="running",
        retries=1,
    )

    assert queued == running
    assert hash(queued) == hash(running)

Do not write the opposite test:

# Incorrect test: distinct objects are allowed to collide.
assert hash(first) != hash(second)

Distinct jobs will usually have distinct hashes, but “usually” is not a correctness guarantee. Tests should enforce the required direction only.

Then verify that a set merges two snapshots of the same job:

def test_set_treats_job_snapshots_with_same_id_as_one_member() -> None:
    queued = make_job(
        job_id=UUID(int=1),
        status="queued",
    )
    running = make_job(
        job_id=UUID(int=1),
        status="running",
    )

    assert {queued, running} == {queued}

Finally, verify dictionary-key lookup through an equal but independent instance:

def test_equal_reloaded_job_finds_dictionary_value() -> None:
    stored = make_job(
        job_id=UUID(int=1),
        status="queued",
    )
    reloaded = make_job(
        job_id=UUID(int=1),
        status="completed",
    )

    jobs_by_entity = {stored: "persisted record"}

    assert jobs_by_entity[reloaded] == "persisted record"

This is the behavior you are enabling: one representation of a job can find an entry originally stored using another representation of that same job.

There is an important application-level boundary here. A set of Job objects is not a fresh-state cache. If both queued and running snapshots exist, the set treats them as one entity, but the retained object may still be the original queued instance. Use entity equality to identify the same durable job; compare state fields or version information when you need to reason about freshness or conflicting updates.

A practical implementation pass:

  1. Move job_id to _job_id and expose a read-only job_id property.
  2. Add Job.__hash__ based only on job_id.
  3. Add the three tests above.
  4. Run pytest.
  5. Temporarily add status to __hash__ and confirm the equal-hash test fails.
  6. Restore the correct implementation.

Dataclasses: useful for values, not an automatic entity solution

You will encounter code such as:

from dataclasses import dataclass


@dataclass(frozen=True)
class RetryPolicy:
    max_attempts: int
    base_delay_seconds: int

A frozen dataclass with generated equality can also receive a compatible generated hash. That is an excellent fit for immutable value objects, such as configuration, retry policies, or immutable request values.

Job is different: its operational state changes. Marking the entire entity frozen would prevent legitimate transitions such as queued to running. Conversely, forcing dataclass hashing for an entity while its equality fields can mutate merely hides a bug.

For this domain model, manual __eq__ and __hash__ make the design decision visible:

  • job_id is immutable identity.
  • status and retries are mutable state.
  • Equality and hashing operate only on identity.

Takeaways

  • A hash is an integer used by sets and dictionaries to narrow lookup candidates; equality confirms the final match.
  • The required contract is: if two objects compare equal, they must have the same hash.
  • Hash collisions are valid and expected; equal objects with different hashes are invalid.
  • Python makes a class unhashable when it defines __eq__ without a compatible __hash__.
  • For the capstone’s Job, implement:
def __hash__(self) -> int:
    return hash(self.job_id)
  • status, retries, and other mutable descriptive fields must not participate in the hash.
  • Hashing a mutable entity is safe only when the identity fields used by both equality and hashing remain stable.

Next, you will move from entity identity to collection behavior by implementing a custom collection that participates correctly in Python’s sequence protocol.

Can't find a good explanation? Sign up and we'll make it for you

Sign up