skip to content

In Python, how do you deduplicate a list of records by one field, keeping the first occurrence?

level: middleimportance: should knowfreq 42%

answer

  1. Sameness lives in a field, not the element
  2. No builtin expresses this one
  3. Keep the keys, not the payloads
  4. Beware the comprehension that keeps the last
  5. Explicit set plus append in one loop

basics

~20 s

Track the derived key yourself: iterate the records, compute the field, and append a record only when its key is not already in a seen set. dict.fromkeys cannot do this, because it keys on the whole element.

solid answer

~40 s

Keep an explicit `seen` set of the derived key and build the output in the same loop: compute `key = record["request_id"]`, skip when it is already in `seen`, otherwise add it and append the record. That is one pass, O(n), and unambiguously keeps the **first** record per key. The tempting one-liner `{r["request_id"]: r for r in records}` is not equivalent: because assigning to an existing key overwrites the value but leaves the key in place, it yields keys in first-seen order but the **last** record for each key. Deduplicating the reversed sequence gets you the first values back but scrambles the ordering, so it is not a fix. `dict.fromkeys` is no help here at all: it hashes the whole element, and two records differing in any other field are different keys.

code

python · 15 lines
python
events = [
    {"request_id": "r1", "score": 0.91},
    {"request_id": "r2", "score": 0.44},
    {"request_id": "r1", "score": 0.12},
]

seen = set()
first_only = []
for event in events:
    rid = event["request_id"]
    if rid not in seen:
        seen.add(rid)
        first_only.append(event)

print(first_only)

go deeper

for a junior

Recall the shape: a set of keys already seen, a result list, and an append that happens only on a miss. Know that a plain set of the records will not work when only one field decides sameness.

for a middle

Explain why the dict comprehension keeps the last record while still ordering keys by first appearance, and why reversing the input does not repair the ordering. Be able to factor the loop into a helper taking a key function.

for a senior

Own the semantics: which occurrence is authoritative, how the key is normalized, and what happens when the same logical record arrives twice with different content. Make the choice explicit in code rather than implicit in an idiom.

for a principal

Frame first-wins versus last-wins as a data contract, not a coding preference. Where events can be replayed, the tiebreak rule belongs in one documented place and the deduplication code should read as an implementation of it.

## The problem Order-preserving deduplication has an easy form and a harder one. The easy form collapses elements that are equal to each other, and `list(dict.fromkeys(items))` solves it. The harder form collapses elements that are equal **in one derived value** while keeping the whole element. Deduplicating a replayed stream of scoring events for a fraud-scoring service by `request_id`, keeping the score that was computed first, is the harder form, and no built-in expresses it. The reason is that `dict.fromkeys` hashes the element itself. Two event dicts with the same `request_id` but different `score` are not equal, so they do not collapse. `set` has the same limitation, plus the elements would have to be hashable to begin with. ## The idiom that is correct ```python seen = set() first_only = [] for event in events: rid = event["request_id"] if rid not in seen: seen.add(rid) first_only.append(event) ``` One pass, one hash lookup per element, output built in input order, first record per key. The `seen` set holds only the keys, not the records, so its memory scales with the number of distinct keys and with the size of a key rather than the size of a payload. Write it as a small helper taking a key function and it composes with everything: ```python def dedupe_by(items, key): seen = set() for item in items: k = key(item) if k not in seen: seen.add(k) yield item ``` Generatorising it means the caller decides whether to materialize, and a caller that only needs the first ten unique records stops after ten. ## The one-liner that quietly does something else The comprehension people reach for first is ```python list({e["request_id"]: e for e in events}.values()) ``` It deduplicates, and it even preserves first-seen key order, so it passes a quick eyeball. But it keeps the **last** record for each key, because assigning to an existing key updates the value while leaving the key where it was. Order comes from the first occurrence; content comes from the last. On a replayed stream where the first score is the authoritative one and later replays carry a partially recomputed score, this is a data-corruption bug that no type checker sees. ```pycon >>> events = [{"id": "r1", "score": 0.9}, {"id": "r2", "score": 0.4}, {"id": "r1", "score": 0.1}] >>> {e["id"]: e for e in events} {'r1': {'id': 'r1', 'score': 0.1}, 'r2': {'id': 'r2', 'score': 0.4}} ``` ## Why reversing is not the fix The usual patch is to build the comprehension over `reversed(events)`, so the last assignment for each key is the earliest record. The values are then right, but the ordering is not: keys now enter the dict in order of each key's **last** occurrence, reversed, which is not the same as first-seen order. For events with ids `a, b, a, b`, the reversed comprehension yields keys `b, a` while first-seen order is `a, b`. Reversing the output does not repair it either, since the two orders disagree in different directions for different inputs. If ordering is part of the contract, do not derive it from a dict built backwards; use the `seen` loop, where the ordering is the loop's own. ## Choosing the key The key must be hashable and must capture exactly the notion of sameness you mean. A single id field is the common case. A composite key is a tuple, `(event["tenant"], event["request_id"])`, which is hashable as long as its parts are. A normalized key is often what you actually want, and getting the normalization boundary wrong is its own bug class: deduplicating email addresses on the raw string keeps `[email protected]` and `[email protected]` as two records, while deduplicating on `s.casefold()` merges them. Decide deliberately, and put the decision in the key function where a reader can see it. ## Cost and scale One pass, average O(1) per lookup, so O(n) overall, with memory proportional to the number of distinct keys. At a 1,200-request-per-minute peak, a batch covering an hour is 72,000 events and at most 72,000 short string keys, which is nothing; the payloads would have been the expensive part, and the `seen` set never holds them. The trap at that scale is not CPU, it is a `seen` set that lives in a long-running process and is never trimmed. ## Interview framing The question separates candidates who know a trick from candidates who know what the trick does. Anyone can produce the dict comprehension. The signal is noticing, unprompted, that it keeps the last record and that the requirement said first.

  • Why does {r['id']: r for r in records} keep first-seen key order but the last record per key?
    Two different rules are at work. A key's position is fixed on first insertion, so the ordering comes from the first occurrence. Assigning to an existing key replaces the value without moving the key, so the content comes from the last occurrence. The result mixes the two, which is exactly why it silently fails a first-wins requirement.
  • How would you make the deduplication lazy so a caller can stop after the first few unique records?
    Turn the loop into a generator function that yields each record the first time its key is seen. The caller drives it, so `itertools.islice` over the generator touches only as many input records as it needs, and the `seen` set only ever grows to the number of keys actually consumed.
  • What would you use as the key when sameness spans two fields?
    A tuple of the parts, such as (event["tenant"], event["request_id"]). Tuples are hashable as long as every element is, and they compare element-wise, so the tuple means exactly "same tenant and same request". Concatenating the fields into one string invites collisions when a value can contain the separator.

saying these in an interview costs you the question

  • Uses a dict comprehension when the first record must win
  • Thinks reversing the input restores first-seen order
  • Reaches for dict.fromkeys on whole records
  • Stores whole payloads in the seen set
  • Rebuilds the seen set inside the loop, making it quadratic

context