A one-line dedupe rule was quietly deleting one in six real job postings from my dataset. Here is how it showed up, and the field I forgot to put in the key.
The symptom was a number I was proud of
After cleaning up a date bug, I looked at my pipeline's own funnel:
18,833 postings scraped → 15,548 after dedupe A 17.4% duplicate rate. I had been treating that as proof the sources were messy and my dedupe was doing heroic work. It is not. On real job-board data, cross-source duplicates sit around 1–3%. Anything much above that is usually not the data being dirty — it is your key being too coarse.
So I stopped celebrating and measured what was actually colliding.
Measuring the collision
I took seven companies (stripe, gitlab, anthropic, figma, brex, ramp, openai), 2,835 postings, grouped them by my dedupe key, and then asked a much more useful question: inside each collision group, how many distinct locations are there?
The key was:
key = (job["company"].lower(), title_key(job["title"])) The answer:
- 194 collision groups
- 188 of them (97%) contained more than one location
- 353 rows merged away, 347 of which were distinct jobs
Real examples, straight from the run:
| Company | Title | Locations collapsed into one row |
|---|---|---|
| stripe | abuse investigator | Dublin and Seattle, San Francisco, New York City |
| stripe | account executive commercial hunter | Chicago and Singapore |
| stripe | account executive platforms hunter | Chicago, Chicago, Illinois, London |
Those are not duplicates. They are separate requisitions, with separate links and usually separate pay ranges. My "clean" dataset was throwing them away.
The fix is one field
def location_key(location): text = clean_text(location or "").lower() text = re.sub(r"[,/|;]+", " ", text) text = re.sub(r"[-–—]+", " ", text) # US-Chicago == us chicago return re.sub(r"\s+", " ", text).strip() key = (job["company"].lower(), title_key(job["title"]), location_key(job.get("location"))) Two details that bit me:
- Normalise the hyphen. Without it,
US-Chicagoandus chicagoare different keys. My first test failure was exactly this. - Fix the fingerprint too. If you build "only return jobs I have not seen" on an id derived from company+title, a second city for the same title looks like an already-seen job and never gets delivered.
What it changed
| Measure | Title-only key | With location |
|---|---|---|
| 7 companies: scraped → deduped | 2,835 → 2,482 (12.5% lost) | 2,835 → 2,824 (0.4% lost) |
| Full pool: scraped → deduped | 18,833 → 15,548 (17.4% lost) | 18,833 → 18,567 (1.4% lost) |
| Postings that state a salary | 6,833 | 7,984 (+16.9%) |
The recovered rows are mostly mid-to-low pay, so the median moved down slightly: $204,750 → $200,000. That is also a lesson — the bug was biasing the dataset upward, because the rows it deleted were disproportionately the many-city, lower-level sales and support reqs. If you only ever look at the median, the bias is invisible.
The rule I now follow
Treat the dedupe rate as an instrument, not a trophy. If it is above a few percent, print the collision groups and look at what differs inside them — location, department, employment type, seniority. The first differing field is almost always the one you forgot in the key.
And the inverse check is just as useful: if your dedupe rate is near zero, you are probably not deduplicating anything at all, and your cross-source duplicates are still in there.
The corrected numbers are live at radarlabs.surge.sh/salary-data.html — 18,567 postings, 7,984 with published pay, 259 companies, free CC BY 4.0 CSV. The age distribution is on a separate page if you came here for the earlier date-field bug. If you would rather not maintain the pipeline, the same data runs as a paid Apify Actor — but read the free CSV first and decide whether it is worth $0.0015 a row.