The inversion everybody writes, applied to data whose values are not quite unique. Count the entries going in and coming out.
codes = {'gb': 'uk', 'uk': 'uk', 'fr': 'fr'}
{value: key for key, value in codes.items()}
# -> {'uk': 'uk', 'fr': 'fr'} three entries in, two out
# The collision is invisible: 'gb' was overwritten by 'uk'
len(set(codes.values())) == len(codes) # -> False, and that is the check
{1: 'one', True: 'yes'} # -> {1: 'yes'}, before you invert anything
- A dict comprehension overwrites in silence
Assigning to a key that already exists replaces the value, so a comprehension over colliding values produces a smaller dictionary and no signal at all. The only survivor is whichever entry came last, which means the answer depends on the insertion order of the input — a property nobody thinks they are relying on until they are.
- Comparing lengths against a set is the whole detection
len(set(values)) == len(mapping) asks 'are the values distinct' using exactly the same equality and hashing that the dictionary itself will use. That last part matters: a hand-rolled check comparing values with == on sorted pairs would be slower and would still have to reproduce hashing rules it does not control.
- 1, 1.0 and True are the same dictionary key
Python’s numeric tower makes 1 == 1.0 == True, and equal objects must hash equal, so all three collapse into one key. That is why {1: 'one', True: 'yes'} has one entry before you invert anything, and why a mapping with the values 1 and True cannot be inverted even though the two look nothing alike on the page.
Check first, build second. Deciding whether the mapping *can* be inverted is a separate question from inverting it, and it is one comparison.
The values are distinct exactly when a set of them is the same size as the mapping. If it is smaller, at least two keys share a value and there is no correct answer to give.
Building the set is also what surfaces an unhashable value — it raises TypeError, which the contract says to let through, so no try block is needed. After the check, the comprehension is safe: no two writes can land on the same key.
def invert_mapping(mapping): values = ____(mapping.values()) if len(values) != len(____): raise ____('cannot invert: some values are shared') return {value: key for key, value in mapping.____()}
def invert_mapping(mapping):
# Building the set does two jobs: it detects shared values, and it
# raises TypeError on anything that could not have been a key anyway.
values = set(mapping.values())
if len(values) != len(mapping):
raise ValueError(
"cannot invert a mapping whose values are not unique"
)
return {value: key for key, value in mapping.items()}
set() hashes and compares values exactly as dict keys are hashed and compared, so anything the set collapses is something the comprehension would have overwritten. That is why the check catches 1 against True without knowing anything about numbers — it is not reimplementing the rule, it is borrowing it. Prefer a check built on the same machinery as the operation it is guarding.
You could wrap the set in a try and skip unhashable values, and you would then return a mapping that is silently missing entries — the same class of failure the ValueError exists to prevent. The contract says the error escapes, which puts the choice where it belongs: with the caller, who knows whether a list-valued entry is a bug or an expected shape.
Once the values are known distinct, the comprehension cannot lose an entry, so the famous one-liner is exactly right — it just needed a line in front of it. That is the general shape of making a fragile idiom safe: keep the idiom, and add the precondition it was silently assuming.
Every version below is correct and verified against the same cases by the build. They are here because knowing which one to reach for is the part that takes judgement, and the trade-off is stated rather than left for you to guess.
def invert_mapping(mapping):
inverted = {}
for key, value in mapping.items():
if value in inverted:
raise ValueError(
f'cannot invert: {value!r} is shared by {inverted[value]!r} and {key!r}'
)
inverted[value] = key
return inverted
One pass instead of two, and the error message can name the exact pair that collided — which is genuinely more useful when the mapping has two hundred entries. The cost is that the guard is now tangled into the building, so the 'can this be inverted' question no longer exists separately. Take this version when the message matters; take the set check when you want the precondition to be one readable line.
If shared values are normal in your data rather than an error, do not write this at all — build a dict of value to list of keys instead, which is a grouping and always well defined. And if you find yourself inverting the same mapping repeatedly, keep both directions side by side and update them together, or use a purpose-built structure like bidict, which enforces one-to-one on insert rather than at the end. Inverting on demand is fine for a one-off; doing it in a loop is a sign the data wants a different shape.
- Return the shared values in the error message, so a caller can see which entries collided rather than being told only that some did.
- Write invert_to_lists alongside it, mapping each value to every key that had it, and decide which of the two belongs in a shared utilities module.
- Make it round-trip: check that invert_mapping(invert_mapping(m)) == m for every case here that does not raise, and work out why the ones that raise cannot round-trip.