Python · Python Essentials

Python: Objects, Equality, Immutability

eq and hash, dataclasses and frozen instances, is versus ==, names and mutable defaults, copies, ABCs and protocols, the MRO, properties and dunder dispatch.

  • 8 min read
  • 21 practice questions
  • 1 graded problem
On this page
  1. Description
  2. Equality and hashing
  3. Dataclasses: value classes in a few lines
  4. Names and objects
  5. Copies, shallow and deep
  6. Which method runs
  7. Which tool for which job?
  8. Common mistakes
  9. Check yourself
  10. Key takeaways
  11. Quick revise
  12. Practice

Description

Every value in Python is an object, and most interview code sooner or later defines its own: a Point, an Interval, a Task in a queue. This lesson is about making those objects behave - comparing them correctly, using them as dictionary keys, making them read-only, and copying them without surprises. It's also about a few ways Python's objects work differently from Java's: there's no method overloading, no final, and a variable never holds an object, only a name for one.

Here's the kind of bug we mean. A grid search stores visited points in a set:

class Point:
    def __init__(self, x: int, y: int):
        self.x, self.y = x, y

    def __eq__(self, other: object) -> bool:
        return isinstance(other, Point) and (self.x, self.y) == (other.x, other.y)


visited = {Point(1, 2)}   # TypeError: cannot use 'Point' as a set element (unhashable type: 'Point')

The equality looks right, yet Python refuses to put the point in a set. By the end of the first section you'll know why, and how to fix it in one line.

We'll go from simple to more technical:

  1. Equality and hashing: __eq__ and __hash__ together.
  2. Dataclasses - the quick way to write value classes.
  3. Names and objects: why b = a doesn't copy, and when += changes both.
  4. Copies: shallow and deep, and what a tuple really protects.
  5. Which method runs: no overloading, abstract classes, and super().

Equality and hashing

== calls a class's __eq__. Sets and dict keys also need a hash: a number computed from the object, used to decide where to store it. The rule that ties them together: objects that are equal must have equal hashes.

That's why Python sets __hash__ to None the moment a class defines __eq__ without __hash__: the default hash (based on the object's identity) would break the rule, so Python makes the class unhashable rather than let it misbehave. (Java allows it and quietly loses entries in a HashSet.)

The fix is to define both, over the same fields:

class Point:
    def __init__(self, x: int, y: int):
        self._x, self._y = x, y

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Point):
            return NotImplemented          # "I don't know how to compare with that"
        return (self._x, self._y) == (other._x, other._y)

    def __hash__(self) -> int:
        return hash((self._x, self._y))    # hash exactly what __eq__ compares

Two details:

  • Return NotImplemented, not False, for a type you don't recognise. It tells Python to ask the other object's __eq__; only if both decline does == fall back to comparing identity.
  • Don't change a key after you've stored it. If an object's hash changes while it sits in a set or dict, it's filed in the wrong place: neither the changed object nor an equal copy of the old one can find it again. That's why mutable classes are usually left unhashable on purpose.

Dataclasses: value classes in a few lines

Writing __init__, __eq__, __repr__ and __hash__ by hand gets old. @dataclass generates them from the field list:

from dataclasses import dataclass, field

@dataclass
class Draft:                               # generates __init__, __repr__ and __eq__
    title: str
    tags: list[str] = field(default_factory=list)

@dataclass(frozen=True)
class Range:                               # frozen: read-only, and hashable
    lo: int
    hi: int

    def __post_init__(self) -> None:       # runs after __init__: a good place to validate
        if self.lo > self.hi:
            raise ValueError(f"lo > hi: {self.lo} > {self.hi}")

Things to know:

  • A plain @dataclass can't go in a set. It generates __eq__, so (as above) __hash__ becomes None. Make it frozen=True and it gets a hash built from its fields.
  • Mutable defaults need default_factory. tags: list[str] = [] would be one list shared by every instance, so dataclasses refuse it with a ValueError. field(default_factory=list) makes a new list each time.
  • Frozen is shallow. A frozen dataclass can't have its fields reassigned (FrozenInstanceError), but a list stored in a field can still be changed - and the object then can't be hashed, because lists aren't hashable. Store tuples or frozensets in frozen classes.
  • dataclasses.replace(r, hi=10) gives a modified copy of a frozen object.

Names and objects

A variable is a name attached to an object. Assignment attaches the name to an object; it never copies anything:

a = [1]
b = a                     # two names, one list
a += [2]                  # += changes the list in place: b is [1, 2] too
a = a + [3]               # + builds a new list, and only a moves to it
print(a, b)               # [1, 2, 3] [1, 2]

For lists, += means "extend this list" while a = a + [...] means "make a new list". With immutable types like ints, strings and tuples there's nothing to change in place, so both forms make a new object.

Identity versus equality. is asks "the very same object?"; == asks "the same value?". Use is only for None and other one-of-a-kind objects. CPython reuses objects for small ints, so is on numbers seems to work for small values and fails for bigger ones - a classic trap.

Class attributes are shared. A list written directly in the class body belongs to the class, so every instance sees the same list:

class Team:
    members = []          # one list for EVERY Team - create it in __init__ instead

Copies, shallow and deep

Most ways of copying are shallow: they copy the outer container and share whatever is inside it.

import copy

grid = [[0, 0], [0, 0]]
copy1 = grid[:]                     # or list(grid), or copy.copy(grid): rows are shared
copy2 = [row[:] for row in grid]    # rows copied too
copy3 = copy.deepcopy(grid)         # everything copied, however deep
copy1[0][0] = 9                     # grid[0][0] is now 9 as well

A tuple can't change, but the objects inside it can. And t[1] += [4] on a tuple holding a list does something surprising: it extends the list and raises TypeError, because += first changes the list in place and then tries to store the result back into the tuple.

For a read-only view of a dict - say, configuration you hand to other code - types.MappingProxyType(d) rejects writes but shows later changes to d; dict(d) is a frozen-in-time copy.

Which method runs

  • No overloading. A class body runs top to bottom, so a second def area(...) replaces the first. Use default arguments, *args, or functools.singledispatchmethod to dispatch on type.
  • Every method can be overridden. Calls always look the method up on the object's actual class while the program runs. There's no final (typing.final is only a hint for type checkers).
  • Abstract classes (abc.ABC with @abstractmethod) fail when you try to create an instance of a subclass that's missing an implementation - Java would catch it when compiling. typing.Protocol describes "anything with these methods", checked by type checkers.
  • Multiple inheritance follows the method resolution order (MRO). For class D(B, C) where B and C both extend A, the order is D, B, C, A. super() means "the next class in that order", so super().hello() inside B calls C.hello, not A.hello.

One more trap: a base class __init__ that calls a method the subclass overrides runs the subclass version before the subclass has finished setting itself up:

class Base:
    def __init__(self):
        self.describe()                # runs Child.describe...

class Child(Base):
    def __init__(self):
        super().__init__()
        self.items = []                # ...before this line

    def describe(self):
        print(self.items)              # AttributeError: 'Child' object has no attribute 'items'

Which tool for which job?

You wantUse
A value object you can put in sets and dicts@dataclass(frozen=True) (or __eq__ + __hash__ over the same fields)
A mutable record@dataclass (unhashable - on purpose)
A list or dict default on a dataclassfield(default_factory=list)
Validation when an object is created__post_init__
A modified copy of a frozen objectdataclasses.replace
A copy of a grid[row[:] for row in grid] or copy.deepcopy
A read-only view of a dicttypes.MappingProxyType
Several call signaturesdefault arguments or singledispatchmethod
An interfaceabc.ABC + @abstractmethod, or typing.Protocol

Common mistakes

MistakeWhat happensDo this instead
__eq__ without __hash__unhashable: can't go in a setdefine both, over the same fields
Returning False for an unknown type in __eq__the other side never gets a sayreturn NotImplemented
Changing an object while it's a dict keythe entry is lostkeep keys immutable
A plain @dataclass in a setTypeErrorfrozen=True
tags: list = [] on a dataclassValueErrorfield(default_factory=list)
is to compare numbers or stringsworks by accident, then fails==
A list in the class bodyshared by all instancescreate it in __init__
grid[:] to copy a gridrows are sharedcopy each row, or deepcopy
Two methods with one namethe first one disappearsdefaults or singledispatchmethod
A base __init__ calling an overridden methodruns before the subclass is readydon't call overridable methods from __init__

Check yourself

Try to answer before opening each one. Recalling beats re-reading.

1. Why can't the Point at the top go in a set, and what's the one-line fix?

Show answer

Defining __eq__ without __hash__ sets __hash__ to None, so the class is unhashable. Add def __hash__(self): return hash((self.x, self.y)), hashing exactly the fields __eq__ compares - or make it a @dataclass(frozen=True).

2. After a = [1]; b = a; a += [2]; a = a + [3], what are a and b?

Show answer

a is [1, 2, 3] and b is [1, 2]. += extended the shared list in place; a + [3] built a new list and moved only a to it.

3. For class D(B, C) where B and C both extend A, which hello does super().hello() inside B.hello call?

Show answer

C.hello. super() follows the object's MRO - D, B, C, A - so the class after B is C, not A.

Key takeaways

  • Equal objects must have equal hashes: define __eq__ and __hash__ together, and return NotImplemented for strangers.
  • @dataclass(frozen=True) gives value objects; freezing is shallow; use default_factory for mutable defaults and __post_init__ to validate.
  • Variables are names: assignment never copies, += changes lists in place, and class-body lists are shared.
  • is is for identity (None); == is for values.
  • Copies are shallow unless you copy the insides too; a tuple doesn't freeze what it holds.
  • No overloading, every method is overridable, and super() follows the MRO.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

Pick the tool

You wantUse
A value object for sets and dict keys@dataclass(frozen=True), or __eq__ + __hash__ over the same fields
A mutable record@dataclass (unhashable on purpose)
A sortable value@dataclass(order=True) or functools.total_ordering
A list default on a dataclassfield(default_factory=list)
Validation at creation__post_init__
A modified copy of a frozen objectdataclasses.replace(obj, field=...)
Many small objects, no typos__slots__ or @dataclass(slots=True)
A grid copy[row[:] for row in grid] or copy.deepcopy
A read-only view of a dicttypes.MappingProxyType(d)
Dispatch on argument typefunctools.singledispatchmethod
An interfaceabc.ABC + @abstractmethod, or typing.Protocol

Snippets worth knowing by heart

def __eq__(self, other):
    if not isinstance(other, Point):
        return NotImplemented
    return (self.x, self.y) == (other.x, other.y)

def __hash__(self):
    return hash((self.x, self.y))          # the same fields as __eq__

@dataclass(frozen=True)
class Range:
    lo: int
    hi: int

tags: list[str] = field(default_factory=list)
grid2 = [row[:] for row in grid]
if value is None: ...                      # is only for None and singletons

The traps

  • __eq__ without __hash__ - the class becomes unhashable.
  • return False for an unknown type - use NotImplemented so the other side can answer.
  • Mutating a dict key - the entry is lost.
  • A plain @dataclass in a set - TypeError; frozen is shallow; = [] defaults raise ValueError.
  • is on numbers or strings - true for 256, false for 257.
  • a += [x] changes the list every other name sees; a = a + [x] doesn't.
  • A list in the class body is shared by every instance.
  • grid[:] shares the rows; t[1] += [x] on a tuple both changes the list and raises.
  • Two defs with one name - the second replaces the first.
  • A base __init__ calling an overridden method - runs before the subclass is set up.

Staff-level points, one line each

  • a == b: a subclass on the right that overrides __eq__ goes first; NotImplemented passes the turn; both decline means identity.
  • Attribute lookup: data descriptors (property, slots) > instance __dict__ > methods and class attributes > __getattr__.
  • f.m makes a new bound method each time (f.m is f.m is False); an instance attribute can shadow a method.
  • __slots__ saves less than it used to (about 120 vs 80 bytes per two-field object on 3.14) but blocks new attributes.
  • Frozen dataclasses raise from a generated __setattr__; object.__setattr__ bypasses it. Their hash equals the tuple of fields; they get __match_args__.
  • eq=True alone sets __hash__ = None; unsafe_hash=True forces one on a mutable class.
  • The MRO is C3: children before parents, bases in listed order; contradictions raise TypeError at class creation.
  • super() is "next in the instance's MRO" - cooperative classes pass **kwargs along.
  • Small ints -5..256 are cached; equal constants in one compiled block may be shared - neither is a promise.
  • deepcopy keeps a memo, so cycles and shared references survive; __deepcopy__ customizes it.

Say it in an interview

  • "I'll make the point a frozen dataclass so it's hashable and I can keep visited points in a set."
  • "__hash__ uses exactly the fields __eq__ compares, so equal points hash equally."
  • "I copy each row - grid[:] alone would share the rows."
  • "super() follows the MRO, so in this diamond B's super call reaches C, not A."

Practice

This skill comes with 21 multiple-choice questions that tell you which misconception a wrong answer reveals and 1 graded problem. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice Python: Objects, Equality, Immutability free

Price Tags in a Set

MediumProCoding problemPython

A shop in Dublin imports price tags from several suppliers. Each tag has an amount in cents and a currency code - but suppliers don't agree on capitals, so the same price arrives as 1200 EUR, 1200 eur or 1200 Eur.

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Practice Python: Objects, Equality, Immutability free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime