Two ways to rename one key. The second one is longer to read and much harder to get wrong.
record = {'a': 1, 'b': 2, 'c': 3}
# Edit in place: the renamed key moves to the end
record['x'] = record.pop('b')
record # -> {'a': 1, 'c': 3, 'x': 2}
# Rebuild: every key keeps its position
renames = {'b': 'x'}
{renames.get(key, key): value for key, value in record.items()}
# -> {'a': 1, 'x': 2, 'c': 3}
# And the danger, in either style
{'a': 1, 'b': 2} # rename b -> a, and one of them is gone
- renames.get(key, key) is the whole translation
Look the key up in the rename table and fall back to the key itself. Keys that are not being renamed pass through untouched, and there is no branch — which also means a rename for a key that does not exist simply never comes up, satisfying the 'ignored, not an error' rule for free. One expression, three rules.
- Rebuilding preserves order; popping does not
Dictionaries remember insertion order, so a key removed and re-added goes to the end. That turns a rename into a reordering — invisible in most code and immediately visible the moment the record is serialised to JSON, written as a CSV row, or compared in a test against a fixture.
- A collision is silent data loss, so it has to be checked
Two keys mapping to one name means the second write overwrites the first, and the result is a record that is simply missing a field. The comprehension will not tell you: it produces a smaller dictionary and no error. Any time a rebuild can produce duplicate keys, count them.
Work out the new name for every key first, then look at whether those new names are all different, and only then build the record.
renames.get(key, key) gives the new name for one key, with 'unchanged' as the fallback. Build the full list of new names for the record’s keys, and compare its length against a set of it — if they differ, two keys are heading for the same place.
Deciding the names in a list first, rather than checking as you build, is what makes 'rename a to a' work — the list is exactly as long as the record and holds each destination once. Then the dict comprehension cannot lose anything.
def rename_keys(record, renames): names = [renames.____(key, key) for key in record] if len(____(names)) != len(names): raise ____(f’renaming would collide: {names}') return { renames.get(key, key): value for key, value in record.____() }
def rename_keys(record, renames):
# Work out every destination first: the collision check is then a
# question about this list, and renaming a key to itself is fine.
names = [renames.get(key, key) for key in record]
if len(set(names)) != len(names):
raise ValueError(
f"renaming would put two keys in the same place: {names}"
)
return {
renames.get(key, key): value
for key, value in record.items()
}
Asking 'is the new name already a key of the record' seems natural and gets both awkward cases wrong: it refuses a rename to itself, and it misses two keys renamed onto a third name that nobody had. Building the list of where everything is going turns both into the same question — are these destinations distinct — with one answer.
renames.get(key, key) appears in the check and in the build, and keeping them identical is what makes the check meaningful — a check that computes destinations slightly differently from the code it guards is worse than no check. If it grew any more complicated it would deserve a name and one definition; at this size, repetition is clearer than a helper.
The comprehension walks the record in its own order and emits each entry once, so positions are preserved without anything in the code mentioning order at all. That is the quiet advantage of building a new structure over editing one: properties you never thought about tend to survive.
If the translation is fixed and known at the boundary, a schema library — Pydantic’s field aliases, marshmallow’s data_key, attrs converters — expresses 'their name for this field is X' where the field is declared, and does validation at the same time. This function is for translations decided at runtime: a mapping loaded from config, a user-supplied column mapping in an import wizard. And if you find yourself renaming keys deep inside nested structures, stop and write the transformation you actually mean rather than a recursive renamer.
- Add a drop_unmapped flag that keeps only the renamed keys, and decide whether that is still the same function.
- Return the renames that did not apply, so a caller can spot a stale mapping table instead of wondering why nothing changed.
- Support renaming in both directions and check that renaming with the inverted table gives the original record back.