collections.dedupe-by-key
Remove records that share a key value, keeping the first or the last of each, in input order.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 14 tests, run in TypeScript, Python and Rust.
What it does
Removes records that share the value at `key`, keeping either the first or the last record of each set of duplicates. `first` is "the original wins" (an import that must not overwrite); `last` is "the latest wins" (a change feed where later rows supersede earlier ones).
WHERE SURVIVORS SIT: every survivor keeps its own position relative to the other survivors. With `keep = last`, the surviving record sits where the last occurrence was, not where the first one was:
For example
dedupe_by_key(records ×3, id, first)→ ×2 keep first: the earliest record of each key survives, in its own placededupe_by_key(records ×3, id, last)→ ×2 keep last: the latest record survives, at the latest record's positiondedupe_by_key(records ×5, id, last)→ ×3 three copies, keep last, interleaved with others
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
def dedupe_by_key(records: Sequence[Mapping[str, Any]], key: str, keep: DedupeKeep) -> List[Mapping[str, Any]]
| records | record[] | open JSON-ish maps; shapes may differ between records |
| key | string | the field whose value identifies a record |
| keep | DedupeKeep | which of a set of duplicates survives |
| returns | record[] | a new list; each survivor stays at its own position in the input |
The type it declares, generated into your project
DedupeKeep = Literal["first", "last"]
Your code names it in one line, in the file that uses it
from fune.collections.dedupe_by_key import dedupe_by_key # collections.dedupe-by-key@^1
from typing import Any, List, Mapping, Optional, Sequence, Set
from .collections_dedupe_by_key_types import DedupeKeep
# Largest integer JavaScript can hold exactly; beyond it the three languages disagree.
SAFE_INTEGER = 9007199254740991
def _key_of(value: Any, key: str) -> Optional[str]:
"""The identity of a record, or None when it has none.
Rendered exactly as collections.group-by-key renders a group name, so the
two capabilities agree on what "the same key" means. Each language's
default string conversion differs (Python prints True, JavaScript true),
so the rendering is spelled out rather than inherited.
"""
# A record with no key is not a duplicate of anything: two records that
# both lack an id are two unknowns, not one thing seen twice.
if value is None:
return None
if isinstance(value, str):
return value
# bool before int: in Python True is an int.
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, int):
if abs(value) > SAFE_INTEGER:
raise ValueError('cannot dedupe by the out-of-range number %d at "%s"' % (value, key))
return str(value)
if isinstance(value, float):
raise TypeError('cannot dedupe by the fractional number %r at "%s"' % (value, key))
raise TypeError('cannot dedupe by the list or map at "%s"' % (key,))
def dedupe_by_key(
records: Sequence[Mapping[str, Any]],
key: str,
keep: DedupeKeep,
) -> List[Mapping[str, Any]]:
"""Remove records that share the value at ``key``, keeping the first or
the last of each set.
Survivors keep their own positions: with "last", the survivor sits where
the last occurrence was, which is what "latest wins" means in a change feed.
"""
if isinstance(records, (str, bytes)) or not isinstance(records, (list, tuple)):
raise TypeError("dedupe_by_key needs a list of records")
if not isinstance(key, str) or key == "":
raise TypeError("dedupe_by_key needs a non-empty key name")
if keep not in ("first", "last"):
raise ValueError('keep must be "first" or "last", received "%s"' % (keep,))
# Every key is rendered up front, so a bad value raises whichever mode runs.
keys = [_key_of(record.get(key) if isinstance(record, dict) else None, key) for record in records]
seen: Set[str] = set()
survives = [False] * len(records)
# Walking backwards for "last" makes the last occurrence the first one seen.
order = range(len(records) - 1, -1, -1) if keep == "last" else range(len(records))
for i in order:
k = keys[i]
if k is None:
survives[i] = True
elif k not in seen:
seen.add(k)
survives[i] = True
return [record for i, record in enumerate(records) if survives[i]]Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add collections.dedupe-by-key
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./collections.dedupe-by-key-1.0.0-python.fune, or fetch it from a terminal with fune pull collections.dedupe-by-key@1.0.0:python.
The whole function, every language, is one file too: collections.dedupe-by-key-1.0.0.fune, 16,235 bytes, sha256 05480a9dc85245dd71b28201102ce3496e166eff75901d8ba8c05fb5ae139f26. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
# fune: before collections.dedupe-by-key
after — your function gets the result and the arguments, and returns the final result.
# fune: after collections.dedupe-by-key
replace — it requires no other capability, so there is no dependency to replace.
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show collections.dedupe-by-key --steps.
# fune: step collections.dedupe-by-key after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| keep first: the earliest record of each key survives, in its own place | records ×3, id, first | → | ×2 |
| keep last: the latest record survives, at the latest record's position | records ×3, id, last | → | ×2 |
| three copies, keep last, interleaved with others | records ×5, id, last | → | ×3 |
| three copies, keep first | records ×5, id, first | → | ×3 |
| no duplicates leaves the list as it was | records ×3, id, first | → | ×3 |
| an empty list stays empty | , id, last | → | |
| records without the key are always kept, never collapsed into one | records ×5, id, first | → | ×4 |
| the number 1 and the string "1" are the same key, as in group-by-key | records ×2, id, last | → | ×1 |
| case matters: "A" and "a" are different keys | records ×2, id, first | → | ×2 |
| booleans are keys too | records ×3, flag, last | → | ×2 |
Show the other 4 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| an unknown keep mode is an error | records ×1, id, middle | → | error: keep must be "first" or "last" |
| an empty key name is an error | records ×1, , first | → | error: needs a non-empty key name |
| a fractional number at the key is an error, not a guess | records ×1, id, first | → | error: cannot dedupe by the fractional number |
| a list at the key is an error | records ×1, id, first | → | error: cannot dedupe by the list or map |
More from the author
[a#1, b#1, a#2] keep first -> [a#1, b#1] [a#1, b#1, a#2] keep last -> [b#1, a#2]
That is what a change feed means by "latest wins": the survivor is the latest row, in the latest row's place. A caller who wants the latest values in the original slot can dedupe with `last` and re-sort.
WHAT COUNTS AS THE SAME KEY: values are compared by the same rendering `collections.group-by-key` uses, so the two agree on what a key is. Strings are themselves, whole numbers are their decimal digits, booleans are `true` and `false`. That means the number `1` and the string `"1"` are the same key, as they are in a CSV, a query string and every JSON API that is loose about types. A fractional number, a list or a map at the key is an error, because there is no rendering of them all three languages agree on; so is a whole number beyond 2^53, which JavaScript cannot hold exactly.
MISSING KEYS ARE NEVER DUPLICATES: a record whose key is absent or null is always kept. Two records that both lack an id are two things we know nothing about, not one thing seen twice, and collapsing them would silently lose data. (This is where dedupe deliberately differs from group-by-key, which puts them in one "" group.)
Errors: a non-list, an empty key name, a `keep` other than `first` or `last`, and an ungroupable value at the key. The input is never modified.
Files
| Path | Bytes |
|---|---|
| README.md | 1,856 |
| impl/python.py | 2,851 |
| impl/rust.rs | 2,902 |
| impl/typescript.ts | 2,951 |
| vectors.json | 2,969 |