Mutable, Immutable & Hashable
In short: objects split into mutable (list, dict, set, most of your
own classes — they can change value while keeping their identity) and
immutable (int, str, tuple, frozenset, bool, None — they can't
change at all). += mutates in place on a mutable object but builds a new object
and rebinds the name on an immutable one — the difference only shows when a second
name is watching. A dict/set key must be hashable, which really means
"guaranteed not to change while it's stored" — that's why a plain list can never be
a key, and why a key's hash must never change once it's filed, or the entry
becomes unreachable even though it's still sitting there.
![Key(["pune"]) filed in slot 6 of a dict's table; after k.fields.append("2026") the same key now hashes to slot 1, which is empty — the lookup says False even though the entry is still sitting in slot 6](/api/assets/python/assets/824d57c44d2da107.png)
+= on a string vs. a list
s += "!" and row += ["b"] look identical. They aren't:
A string can't grow, so += has to build a new one and rebind s. A list can
grow, so += calls its in-place __iadd__ and keeps the same identity. The gap
only shows when someone else is still holding the original:
t = (1, 2)
u = t
t += (3,)
print(u)A tuple that still isn't fully frozen
A tuple locks which objects it holds, not whether those objects can change:
t = ([1, 2], "x")
try:
t[0] += [3]
except TypeError:
pass
print(t[0])A tuple's immutability is shallow: it guarantees its own slots never point somewhere else, and says nothing about what those slots contain.
A dict key that goes missing
A dict doesn't search its keys one by one — it hashes the key to pick a slot, then
confirms with ==. That only works if a key's hash is stable, which is exactly
what a mutable value can't promise. The slot numbers below are only for
illustration, since the real ones depend on the table's size:
A dict files each key in the slot its hash picks. If a key's hash changes after it is filed, every later lookup searches the wrong slot.
The tuple ("pune", 2026) hashes to a number that picks slot 3, so the key is filed there. 8 cells. At index 3.
The entry never left. Step through the same class and watch found:
After the append, found is False even though still_there is True. The
lookup hashes the key's new value, goes to the wrong slot, and finds nothing.
Rebuilding the dict (the last line) fixes it, because every key gets hashed
again — but nothing tells you when a rebuild is needed, so that isn't a real
fix. k's hash changed after it was filed, so a lookup now starts at the wrong
slot and finds nothing. The real fix: hash only what can't change.
Freeze the fields into a tuple, and refuse to let them be reassigned:
Any built-in mutable type refuses to hash at all rather than risk this:
hash([1, 2]), hash({"a": 1}) and hash((1, [2])) all raise TypeError — even
a tuple is only hashable if everything inside it is.
Freeze a row before using it as a key
Quarry's rows are lists and dicts, straight from JSON — never hashable as they are. To dedupe them, freeze each row into an equal-meaning immutable value, recursively:
frozenset for a dict's items ignores order, the same way dict equality does.
One trap worth knowing: True == 1 and hash(True) == hash(1), so {1, True}
has a single element. If a column might hold either, freeze (type(v).__name__, v) instead of the bare value.
Quick practice
results = []
for value in ([1, 2], (1, 2), {"a": 1}):
try:
hash(value)
results.append("hashable")
except TypeError:
results.append("unhashable")
print(" ".join(results))Each call should start its own log. Run it, notice a and b are the same list, then fix the default.
🎯 Your turn
Write dedupe_rows(rows) for the inspector. rows is a list of JSON values —
lists, dicts, strings or numbers, nested to any depth. Return the rows with
duplicates removed, keeping the first copy and the original order. Two rows
are duplicates if they're equal, including dicts whose keys appear in a different
order.
dedupe_rows([[1, 2], [1, 2], [2, 1]])→[[1, 2], [2, 1]]dedupe_rows([{"id": 1, "tags": ["a"]}, {"tags": ["a"], "id": 1}])→[{"id": 1, "tags": ["a"]}]
Hint: freeze each row (lists → tuples, dicts → frozensets of (key, frozen value) pairs, recursively), and use a set of frozen forms to check "seen before"
in one hash lookup instead of comparing against every row so far.
Press ▶ Run, then ✓ Check. ✅
