My dashboard had a line I almost shipped as a footnote: 0.1% of salary rows can't be converted to USD.
That reads like a rounding error. It wasn't. It was my filter deleting 1,170 real job postings.
The setup
I run ATS Job Feed, which pulls open roles from Greenhouse, Lever, Ashby, Workable, Personio and Workday, normalises pay to yearly USD, and de-duplicates across boards. One of the switches, salaryOnly, drops postings that don't state a number — people who only care about jobs with published pay.
Under the hood the filter is one line:
if salary_only and not (job.get("salaryLow") or job.get("salaryHigh")): continue And the conversion was a dict:
TO_USD = {"USD": 1.0, "EUR": 1.08, "GBP": 1.27, "CNY": 0.14, ...} # ten currencies Anything not in that dict returned None. Which meant: a posting quoted in PLN, AED, MXN, RON, BRL or MYR got salaryUsd* = None, and salaryOnly then treated it as "no salary published" and dropped it.
The posting was real. The pay was real. The number was on the page. My pipeline just couldn't convert it, and the filter read "can't convert" as "nothing to see".
The failure mode worth remembering
A missing conversion is indistinguishable from missing data.
Once a derived field feeds a filter, every failure of the derivation becomes a confident "no" instead of an honest "unknown". Nobody logs it, because None is a legal value. My coverage stat even printed it as a tiny percentage instead of an error, because I'd written the stat to measure "rows without USD" — which is exactly the symptom, not the cause.
If I had shipped that footnote, a customer filtering to salaried-only roles would have silently lost 1,170 postings in six currencies and had no way to notice. That's the bad kind of bug: not a crash, a quiet omission.
The fix
- Fetch a dated rate table once per run. open.er-api.com needs no key, covers 166 currencies, and returns its own update timestamp.
- Cache it for 24 hours so 259 company fetches don't become 259 HTTP calls, and a network blip doesn't take out the run — on failure it falls back to a static table and logs a warning.
- Invert honestly. The API says "how much local currency per 1 USD". I need "what do I multiply by". That's
1 / rate, and it deserves its own test. - Never guess. Unknown currency →
null, not 1.0, not the previous row's rate. - Publish the provenance. Every run reports
fxDatein its summary:Fri, 23 Oct 2026 00:02:31 +0000. A converted number you can't trace to a date is a rumour. - Keep the original numbers. Output carries
salaryCurrencyplus the local low/high, so a GBP posting shows both375,000–640,000 GBPand502,236–857,149 USD.
What it actually moved
| before | after | |
|---|---|---|
| rows with a USD midpoint | 7,994 | 8,004 |
| rows where USD midpoint is missing | 10 | 0 |
| non-USD rows that convert | partial | 777 |
salaryOnly pass-through, 259-company pool | 6,837 | 8,005 (+17%) |
Sample: anthropic, GBP 375,000–640,000 → USD 502,236–857,149. Currency mix across the paid rows: USD 7,227 / EUR 405 / CAD 169 / GBP 155 / SGD 10 / MXN 9 / AUD 6 / INR 6 / AED 5 / CNY 4 / BRL 3 / PLN 2 / RON 2 / MYR 1.
Three checks you can copy
- Split "unknown" from "no". If a derived field gates a filter, log
derived is Noneseparately fromfield is absent. They look identical downstream and mean opposite things. - Make derivations self-describing. Rate date, timezone, source version — print it in the artefact, not just in a log line you'll never read.
- Test the unknown path. One test per branch: static rate works, live rate overrides it, unknown currency stays
null. That third test is the one that keeps the bug from coming back.
The feed is on Apify — $0.0015 per de-duplicated posting with a normalised annual salary, no charge for rows that get filtered out. The same data is free as a CSV (8,004 rows, CC BY 4.0), and you can look up a single company on this page.
If you're building something similar and you've got a filter sitting downstream of a conversion step — go count the Nones. They may not be as small as they look.
![Why Object.keys(user) Returns string[], Not (keyof User)[]: TypeScript Means It](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2q4w48b9bzs43tynyhsf.png)
