Good to continue. In the previous lesson, you used __new__ to ensure that every RetryCount instance is valid at creation time. That type naturally behaves like an integer: two RetryCount(3) values already compare by numeric value.
Ordinary user-defined domain classes begin differently. Unless you define otherwise, Python considers two separately created instances unequal, even when their attributes look identical. This lesson defines what “the same job” means in the capstone and implements that decision through __eq__.
By the end, you will be able to make independently loaded Job objects compare equal when they represent the same domain entity, while preserving correct behavior with unrelated types and subclasses.
Equality is a domain decision, not an attribute-counting exercise
Python offers two importantly different comparisons:
isasks whether two references point to the same runtime object.==asks whether two objects are equal according to their equality contract.
is is always about identity. It never calls __eq__.
first = object()
second = object()
alias = first
assert first is alias
assert first is not second
For a class with no custom equality, == also effectively behaves like identity comparison:
class PlainJob:
pass
first = PlainJob()
second = PlainJob()
assert first is not second
assert first != second
That default is sensible because Python cannot infer your domain’s meaning of equality. Consider two Job objects obtained from separate database queries. They are distinct Python objects, but they can represent the same durable job record.
For the job-service capstone, use this definition:
Two
Jobinstances are equal when they have the samejob_id.
The mutable or descriptive fields of a job, such as its status and retry count, do not decide whether it is the same job. They describe its current state.
This makes equality useful in application code:
stored_job == job_reloaded_from_database
may be true even though the two variables refer to different objects.
Read the protocol rule first
Python’s data model specifies that == is implemented through the rich-comparison protocol and explains the role of the NotImplemented sentinel.
3. Data model — Python 3.14.2 documentation
Read the official Python documentation to establish the precise behavior of __eq__, especially when the other operand is of a different type.
In the object.__eq__ entry under the rich-comparison methods, begin at the paragraph following the operator-to-method correspondence list. Read the comparison protocol through the final paragraph of that entry. Focus on three points: NotImplemented is a return value rather than an exception, default equality is identity-based, and a subclass on the right side can receive priority in a mixed-type comparison.
The most useful operational model is:
- Python invokes an equality method for the operands.
- A method that does not know how to compare the pair returns
NotImplemented. - Python gives the other operand an opportunity to define the comparison.
- If neither side handles equality,
==falls back to identity comparison.
The exact internal dispatch rules have a few subtleties around subclasses, but the design rule is simple: return NotImplemented when the other operand is outside your class’s equality contract.
NotImplemented is not the same as NotImplementedError:
NotImplementedis a singleton value returned by special methods.NotImplementedErroris an exception, commonly used for incomplete abstract methods.
This short video gives a useful concrete view of why returning False too early can prevent the other operand from participating.
"NotImplemented" is Awesome in Python
Watch “NotImplemented is Awesome in Python” by Indently for a compact demonstration of the sentinel’s role in custom comparisons.
Watch the distinction to separate the sentinel from the similarly named exception. Then watch the equality example and the fallback logic. Notice that NotImplemented means “this operand cannot decide,” not “the two objects are unequal.”
Implement equality for the Job entity
A job has several fields, but only one is its durable identity: job_id.
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
def __eq__(self, other: object) -> bool:
if self is other:
return True
if type(other) is not type(self):
return NotImplemented
return self.job_id == other.job_id
There are four deliberate design choices here.
1. other is annotated as object
Equality can be attempted with absolutely any Python object:
job == "job-123"
job == None
job == 42
Therefore, other should begin as object, not Job. Your implementation must narrow it before accessing .job_id.
After this check:
if type(other) is not type(self):
return NotImplemented
Python knows at runtime that other is the same concrete type as self, so accessing other.job_id is valid.
2. The identity fast path is correct but optional
if self is other:
return True
An object must compare equal to itself. The later job_id comparison would also produce True, so this branch is primarily a fast path and an explicit statement of the equality law.
3. Exact-type comparison is intentional here
This lesson uses:
type(other) is not type(self)
That says equality is defined only between two instances of the same concrete Job class.
An alternative is:
if not isinstance(other, Job):
return NotImplemented
That alternative is appropriate only if every future subclass of Job should share exactly the same equality contract. For example, if a PriorityJob adds a priority field that contributes to equality, isinstance() could create surprising or asymmetric behavior between Job and PriorityJob.
Use exact types by default when subclasses might later add meaning. Use isinstance() when shared equality across an intentional class family is genuinely part of the design.
4. Only semantic identity fields belong in equality
This line is the heart of the contract:
return self.job_id == other.job_id
Do not compare every attribute merely because it exists. In this entity model:
| Field | Included in equality? | Reason |
|---|---|---|
job_id | Yes | Identifies the same durable job |
owner_id | No | An attribute of the job, not its identity |
job_type | No | Describes requested work |
retries | No | Can change during processing |
status | No | Can change from queued to running to completed |
This means two snapshots can be equal while differing in state:
from uuid import UUID
job_id = UUID(int=1)
queued = Job(
job_id=job_id,
owner_id=UUID(int=10),
job_type="thumbnail",
retries=RetryCount(0),
status="queued",
)
running = Job(
job_id=job_id,
owner_id=UUID(int=10),
job_type="thumbnail",
retries=RetryCount(1),
status="running",
)
assert queued is not running
assert queued == running
That does not claim that the snapshots have identical data. It says they refer to the same domain entity. If the application needs to detect a state conflict, it should compare version numbers, timestamps, or individual state fields explicitly rather than overload entity equality to mean “identical snapshot.”
Why NotImplemented is better than False
Suppose you compare a Job to an unrelated object:
job == "not a job"
Your Job.__eq__ returns NotImplemented. Usually, the string’s comparison method will also decline the comparison, and Python will ultimately produce False.
So this remains true:
assert job != "not a job"
But the intermediate meaning is more accurate:
assert job.__eq__("not a job") is NotImplemented
Returning False would mean, “I understand this comparison and the values differ.” Returning NotImplemented means, “This class does not define equality with that type.”
That distinction matters when another type does know how to compare itself to a job. For example, an adapter object could intentionally represent a job reference from another system. Returning NotImplemented gives that adapter a chance to define a symmetric comparison rule.
You normally do not call __eq__ directly in application code. Use ==; direct calls are useful here only to observe the protocol’s sentinel value.
Preserve the basic laws of equality
A usable equality relation should satisfy a few practical laws:
- Reflexive: every object equals itself.
- Symmetric: if
a == b, thenb == a. - Transitive: if
a == bandb == c, thena == c. - Stable in meaning: equality should not depend on incidental runtime details such as memory addresses or a mutable status field.
Comparing job_id meets these requirements because UUID equality already compares values consistently.
By contrast, this would be an unstable and misleading equality implementation:
def __eq__(self, other: object) -> bool:
if not isinstance(other, Job):
return NotImplemented
return (
self.job_id == other.job_id
and self.status == other.status
and self.retries == other.retries
)
Now a job can become unequal to an independently loaded representation of itself simply because its processing state changed. That may be correct for an immutable snapshot value, but it is not correct for an entity whose identity persists across state transitions.
A useful design question is therefore:
Does this class model an entity that remains the same while its state changes, or an immutable value whose complete contents define it?
Job is an entity. The RetryCount from the previous lesson is a value type. Python uses the same __eq__ mechanism for both, but your domain model determines which fields matter.
Test the contract through observable behavior
Create focused tests for the decisions above. The helper keeps the tests centered on equality rather than constructor noise.
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, independently constructed jobs with the same durable identity should compare equal:
def test_jobs_with_the_same_id_compare_equal() -> None:
first = make_job(job_id=UUID(int=1))
reloaded = make_job(
job_id=UUID(int=1),
status="running",
retries=1,
)
assert first is not reloaded
assert first == reloaded
assert not (first != reloaded)
The is not assertion is important: it proves this is value-based domain equality, not accidental aliasing of one object.
Next, distinct IDs must produce inequality:
def test_jobs_with_different_ids_compare_unequal() -> None:
first = make_job(job_id=UUID(int=1))
second = make_job(job_id=UUID(int=2))
assert first != second
Finally, verify that the class declines comparisons it does not own:
def test_job_declines_equality_with_an_unrelated_type() -> None:
job = make_job(job_id=UUID(int=1))
assert job.__eq__("job-1") is NotImplemented
assert job != "job-1"
A productive implementation pass is:
- Add
__eq__toJob. - Add these tests.
- Run
pytest. - Temporarily change the final comparison to
self.status == other.status. - Confirm the “same ID” test fails.
- Restore comparison by
job_idonly.
That failure is valuable: it verifies that the test suite protects the domain definition rather than merely executing the method.
Equality changes the class’s hashability
There is one consequence to recognize but not solve yet: once a class defines __eq__, Python makes it unhashable unless you explicitly provide a compatible __hash__.
So, after adding this method, this should not be treated as valid application behavior yet:
jobs = {queued, running}
Python does this defensively. If equal objects could have different hashes, dictionary and set lookups would become unreliable. The next lesson will implement a hash that is consistent with Job equality and explain when a mutable domain entity should or should not be hashable.
Takeaways
iscompares runtime identity;==invokes the equality protocol.- Default user-defined-object equality is identity-based.
- A domain entity’s equality fields must be chosen from domain meaning, not from all available attributes.
- For the capstone’s
Jobentity,job_idalone defines equality; status and retry count describe changing state. - Annotate
otherasobject, narrow it before attribute access, and returnNotImplementedfor unsupported operand types. - Exact-type checks are a conservative default when subclasses may acquire different equality semantics.
- Tests should establish equal independent instances, unequal different identities, and correct behavior with unrelated types.
Next, you will make Job safely usable in sets and as dictionary keys by implementing __hash__ so it remains consistent with this equality contract.
Can't find a good explanation? Sign up and we'll make it for you
Sign up