# 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.

Source: https://hyperjobs.io/blog/salary-data-honestly

*HyperJobs Team · 7 July 2026 · 8 min read*

<Note>
  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.
</Note>

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](/docs/guides/salary) 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 A | Job B |
| --- | --- | --- |
| `unit` | `hour` | `year` |
| `min` | **45** | **190000** |
| `annual_min` | 93,600 | 190,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

<Warning title="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.
</Warning>

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](/docs/reference/coverage) 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 `total`s 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.

<Warning title="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](/docs/errors), 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](/docs/guides/skills-and-taxonomies)
  before blaming disclosure rates.
</Warning>

## 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](/docs/guides/salary) 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](/docs/guides/deduplication) 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](/docs/pagination).
