All posts
Insights

Reading salary data honestly

What the salary fields mean, why coverage is partial by construction, and what salary_gte's range-overlap semantics do and don't promise.

HyperJobs Team8 min read

Revised 2026-07-16 for the v1 launch. The original version of this post spent a section on salary_min, a draft-era filter that compared against the wrong end of the range under a misleading name. That filter never shipped: v1 replaced it with salary_gte/salary_lte, which keep the useful semantics under honest names — and added the salary_currency filter this post used to wish for.

Salary is the field people reach for first and misread fastest. Not because the parsing is bad, but because the numbers are cooperative: they are always present when you ask for them, they always compare, and the comparison always returns an answer. There is no failure mode here that looks like a failure.

This post is about the four ways a salary aggregate goes wrong, and the shape of a claim the data can actually support.

The object#

JSON
{
  "salary": {
    "min": 180000,
    "max": 240000,
    "currency": "USD",
    "unit": "YEAR",
    "annual_min": 180000,
    "annual_max": 240000,
    "summary": "$180k–$240k"
  }
}

Illustrative, but structurally exact — salary data has the field-by-field reference. Two things to notice before anything else.

min and max are in whatever unit says. unit is per-posting, and it can be YEAR, HOUR, MONTH, WEEK or DAY.

The whole salary object is null when every field would be null. It is not an object full of nulls, so job.salary.annual_min throws on a posting that never stated a figure. Check job.salary first.

Mistake 1: comparing min across postings#

This is the one that produces confidently wrong numbers.

Job AJob B
unithouryear
min45190000
annual_min93,600190,000

Compare on min and Job A looks four thousand times cheaper. It is not — it pays about half. Sort a mixed result set by min and every hourly posting sinks to the bottom regardless of what it pays. Filter min >= 100000 and you drop a $60/hour contract worth roughly $125k a year.

annual_min and annual_max are the only cross-posting-comparable fields. Use min/max/unit for display — "$45–60/hr" is what a candidate needs to see — and annual_* for every filter, sort, threshold and aggregate.

The annualisation also guards arithmetic that trips up hand-rolled versions: an hourly rate multiplies by ~2,080 and not by 52, and a semi-monthly figure is already half-monthly, so multiplying it by 12 double-counts.

Mistake 2: mixing currencies#

Normalisation is temporal only

It converts a period into a year. It does not convert a currency into another currency. A €95,000 role and a $95,000 role contribute the identical number to this average:

JS
// Wrong: mixes currencies into one distribution.
const avg = jobs.reduce((s, j) => s + j.salary.annual_min, 0) / jobs.length;

Any benchmark, histogram or "top paying" list built this way is silently wrong in proportion to how international your result set is. Pin to one currency — server-side, since salary_currency exists for exactly this:

BASH
--data-urlencode "salary_currency=USD"    # comma = OR: "USD,EUR" takes either

What still has to happen client-side is conversion — the API never turns euros into dollars, so an international comparison needs your own FX rates.

Note the interaction with the previous mistake: a currency filter does not save you from a unit mistake, and annualising does not save you from a currency mistake. They are independent, and you need both.

Mistake 3: reading salary_gte as a floor#

Here is the subtle one. salary_gte=N compares against the posting's annual maximum:

SQL
CAST(salary_annual_max AS REAL) >= N

That is range-overlap — "jobs whose band reaches N" — and it is the documented, deliberate semantics, not a quirk: gte tells you which end it tests, where the draft-era filter this replaced was called salary_min and lied about it. A job advertised at $90k–$210k matches salary_gte=200000, because it could pay 200k. If "could pay N" is your question — and for "show me roles worth applying to" it usually is — the filter answers it directly, with salary_lte bounding the other end and salary_currency pinning the denomination.

But two consequences still deserve your attention:

It is not "pays at least N". The $90k–$210k job is in your result set, and its floor is far below your threshold.

It drops postings with no upper bound — even when the lower bound obviously qualifies. A job listed at "$250,000+" has annual_max: null, a comparison against null is never true, and the posting vanishes from salary_gte=200000. The highest-paying roles are exactly the ones most likely to be open-ended, so a strict-floor question answered with salary_gte is biased against the roles you were looking for.

A true floor filter#

For "the advertised floor is at least N", cut the result set server-side, then apply the real predicate client-side:

JS
const qs = new URLSearchParams({
  skill: "rust",
  has_salary: "true",
  salary_currency: "USD",
  limit: "200",
});
 
const { data } = await fetch(`https://api.hyperjobs.io/v1/jobs?${qs}`, {
  headers: { Authorization: `Bearer ${process.env.HYPERJOBS_KEY}` },
}).then((r) => r.json());
 
const FLOOR = 150_000;
 
const matches = data.filter((j) => {
  const s = j.salary;
  // has_salary only guarantees min OR max is set — annual_min can still be null
  // on an "up to $200k" posting.
  return s?.annual_min != null && s.annual_min >= FLOOR;
});

That null check is not defensive padding. has_salary=true tests salary_min IS NOT NULL OR salary_max IS NOT NULL. It is not a promise that both bounds exist, and definitely not that annual_min does.

Mistake 4: forgetting what has_salary selects#

The coverage snapshot in the docs puts parsed salaries at a little over a third of postings — read /v1/meta for the live figure, and expect it to move. The share matters less than what it is.

It is a parsing result, not a coverage gap being worked through. A posting either advertised a figure or it did not. No amount of filtering conjures the rest, and no future release fills them in.

So has_salary=true does not narrow the dataset to the jobs that pay well. It narrows it to the jobs that said what they pay — and that is not a random subset. Whatever drives disclosure (local pay-transparency rules, employer policy, seniority, how competitive the role is) is plausibly correlated with the compensation itself. The direction of that correlation is not something this dataset can tell you, which is precisely the problem: you cannot correct for a bias you cannot measure.

You can bound the exposure, though. Compute your disclosure rate alongside your aggregate:

BASH
# Denominator: all postings matching your slice.
curl -G https://api.hyperjobs.io/v1/jobs -H "Authorization: Bearer $HYPERJOBS_KEY" \
  --data-urlencode "skill=rust" --data-urlencode "time_frame=30d" --data-urlencode "limit=1"
 
# Numerator: the ones that disclosed.
curl -G https://api.hyperjobs.io/v1/jobs -H "Authorization: Bearer $HYPERJOBS_KEY" \
  --data-urlencode "skill=rust" --data-urlencode "time_frame=30d" \
  --data-urlencode "has_salary=true" --data-urlencode "limit=1"

The ratio of those two totals is the fraction of your slice your salary aggregate actually rests on. Publish it next to the aggregate. If it moves between two slices you are comparing, the comparison is between two different selection processes, not two salary levels.

The one silent zero left in this workflow

Most input mistakes fail loud now — a typo in seniority, an unknown ISO code in country, a malformed time_frame are each a 400 naming the parameter, not a plausible number. The residual is skill: an open vocabulary, so a displayed value fed back as skill=Machine Learning is a quiet total: 0 rather than an error. When a salary slice looks implausibly thin, check the skill token before blaming disclosure rates.

The claim the data supports#

Getting a comp benchmark right is mostly the four rules above applied at once: bucket on annual_min, pin to one currency with salary_currency, refuse rows that cannot answer the question, and use salary_gte only for the question it answers — "could pay N", never "pays at least N". Salary data has a worked histogram.

Then two things to say out loud when you publish it.

It describes postings, not people. Each row is one advertised range, not one filled job. A company that posts the same role in twelve cities contributes twelve observations — collapsing folds those into one if that is not what you want.

It describes postings that stated a salary. Report it as "the range advertised by roles that disclose", because that is the only claim the data actually supports. It is a narrower sentence than the one you wanted to write. It is also the one that survives someone checking it.

One more, if you sampled: a single limit=200 page is newest-first, which makes it a recency sample and not a random one. For a distribution rather than an anecdote, page the full filtered set — see pagination.