The comma means OR, except for skill
taxonomies=Software,Healthcare is a union now. Why v1 made the comma OR on every multi-value filter but one — and why skill still intersects.
Revised 2026-07-16 for the v1 launch. This post originally ran as "There is no OR in this API", describing a pre-release draft where every comma was an AND. That draft never shipped. v1 gave the comma its facet-search meaning — and kept exactly one intersection, on purpose. This is the post explaining both choices.
curl "…/v1/jobs?taxonomies=Software,Healthcare" # → the union: health-tech and everything beside itThat query means what it looks like it means: postings tagged Software or
Healthcare. The comma reads like OR in every faceted search you have ever
used, and here it is OR.
It wasn't always going to be. A pre-release draft of this API ANDed that comma
— the same request returned total: 3, the near-empty intersection of two job
families, wearing the costume of a market insight about how rare health-tech
hiring is. The number was plausible, the semantics were wrong, and nothing in
the response said so. Watching people hit that in testing is why v1 ships the
comma you expected.
One comma, two meanings#
| Syntax | Means | |
|---|---|---|
| Between filters | ?skill=rust&has_salary=true | AND — both conditions hold |
| Inside a filter | ?seniority=Director,Management | OR — either value matches |
Inside skill — the exception | ?skill=rust,kubernetes | AND — both tokens on the posting |
Parameters AND with each other; values inside a parameter OR with each other;
and skill, uniquely, intersects its list. Getting the exception wrong now
fails loud rather than plausible — a wrong enum member in any comma list is a
400 naming the allowed values, not a silent zero.
Why this shape? Look at what the fields are.
Enum fields hold one value per posting. A job has one seniority, one
work_arrangement, one language. AND across seniority=Director,Management is
not a stricter query — it is a contradiction, and it returns zero rows forever.
The draft's semantics made the most natural multi-value query on these fields
unsatisfiable by construction. Even employment_type, which is technically an
array, is nearly always a single value: FULL_TIME,INTERN as an intersection
described a paid full-time internship, a population of approximately nobody.
On single-valued vocabulary, the only combination worth expressing is the
union, so that is what the comma builds.
Skills compose. A posting carries many skill tokens, and real requirements
stack: skill=react,typescript,graphql — a role that needs all three — is the
query people actually write, one condition at a time, the way a job spec is
written. Make skill an OR and that query becomes unwritable in one request,
while the union it would buy ("React shops or TypeScript shops") is the rarer
need. Requirements intersect; categories union. The comma follows the data.
Every AND is still a coverage filter#
The deep cost in this API was never the comma. It's this, and it survives the redesign untouched.
A filter on skill does not just select postings with that skill. It selects
postings that were classified for skills at all — and then, within those, the
ones carrying your token. The postings nobody classified are not "unknown";
they are gone, silently, before your value is ever compared.
- country
- 96.8%
- skills
- 92.4%
- taxonomies
- 90.3%
- education
- 71.5%
- salary
- 36.6%
Read those as filter costs, because that is what they are. No query containing
skill= can see more than the classified 92%; has_salary=true discards
roughly two thirds of the index before your other filters apply. Stack three
classifiers and the result is far smaller than any of them — and you cannot
compute how much smaller from the percentages, because you do not know how the
classifiers correlate. The API publishes the rates
(coverage_pct, refreshed live, so these bars
date more gracefully than the ones they replaced) — it cannot publish the
correlations.
So the rule from skills & taxonomies
still stands, and OR makes it easier to follow: don't stack classifiers
defensively. Adding taxonomies=Software to a skill=rust query to "make
sure" does not make the result more correct — it throws away every Rust posting
that carries no taxonomy, and those postings were not less Rust-shaped. They
were less classified. Use taxonomies to scope, with a comma-union wide enough
to be honest: taxonomies=Software,Technology,Engineering is one request now,
not a defensive intersection.
A null is not a no#
The same mistake in a different costume, unchanged by the redesign.
visa_sponsorship: null means the posting was silent — not that
sponsorship is unavailable. education_requirements: null means no stated
requirement, not "no degree required". The education vocabulary covers well
under a third of the dataset, because most postings simply do not say.
An AND on a sparse field is a much stronger statement than it looks
?education=bachelor degree does not mean "roles where a bachelor's is
enough". It means "roles that explicitly wrote down a bachelor's requirement"
— which excludes the large majority of jobs that never mentioned education,
most of which would happily take one.
The filter is testing what the posting said, not what the employer
wants. Those are different populations, and only one of them is in the
index. (visa_sponsorship=only|exclude has the same property: exclude
keeps the silent majority, it doesn't certify anything.)
Leadership is one request now#
The draft-era version of this post had a section here proving that "leadership
roles" — Management (28,749 postings) plus Director (26,465), distinct
values, neither containing the other — cost two requests merged by hand on
id. It now costs a comma:
curl "…/v1/jobs?seniority=Director,Management"The same collapse happens across the classifiers. Software, Technology and
Engineering are three separate taxonomies, none implying another, and
"technical roles, broadly" was three requests unioned client-side. It's one
parameter now. Every merge-and-dedupe loop in that section of the old post —
the Map keyed by id, the double-counting warning, the quota arithmetic of
fanning out five requests per user search — deleted, replaced by the
punctuation it was reimplementing.
Where you still fan out#
One intersection is deliberate, so one union still costs requests: OR across
skills. skill=rust,kubernetes means both; "Rust or Go" is two requests
merged on id:
const key = process.env.HYPERJOBS_KEY;
async function anySkill(values, rest = "") {
const pages = await Promise.all(
values.map((v) =>
fetch(
`https://api.hyperjobs.io/v1/jobs?skill=${encodeURIComponent(v)}${rest}&limit=200`,
{ headers: { Authorization: `Bearer ${key}` } },
).then((r) => r.json()),
),
);
// A posting can carry both skills; dedupe or your union double-counts.
const byId = new Map(pages.flatMap((p) => p.data).map((j) => [j.id, j]));
return [...byId.values()];
}
// "(Rust or Go) and Kubernetes" — the AND rides inside each request's comma.
const jobs = await anySkill(["rust,kubernetes", "go,kubernetes"]);N branches is N requests, and requests are
quota — the fan-out advice from the old post still applies
to this one filter. If the OR you want is over title words rather than skill
tags, you don't need the fan-out at all: title=rust OR go is Google-style
search, and title_advanced=(rust | go) & !intern
gives you grouping and negation in one request.
The other thing you still cannot say in one request is OR across different parameters — "remote, or pays over 150k" spans two fields, and parameters AND. That one is fan-out by design, and it's rare enough to deserve being visible in your code when you mean it.
What the redesign actually bought#
The draft's position — documented as a known gap, with the workaround — was
that a filter language with OR is a query language, and a query language over a
full-scan hot path buys expressiveness you pay for on every call. That argument
proved half right. The expensive general machinery is still fenced off: free
grouping and negation live in *_advanced search,
where the full-text index can pay for them; structured negation is three
purpose-built parameters (exclude_organization, exclude_source,
visa_sponsorship=exclude) rather than a ! you can attach anywhere.
But the comma union on enum fields was never the expensive part — it's an IN
list, the cheapest disjunction a database knows. The draft was charging clients
N requests to avoid a construct that costs the server nothing. v1 keeps the
fence and moves it to where the cost actually is.
The semantics you can check by reading the URL are still the ones worth having. They just say OR now, out loud, everywhere except the one place requirements really do intersect.
The full filter vocabulary and every matching rule are in filtering jobs.
Keep reading
The skill you were shown is not the skill you filter on
Skills go out display-formatted and come in as canonical tokens. Why ?skill=Machine Learning returns zero rows, and the heuristic that isn't an inverse.
8 min read
How to measure remote hiring share
The query that answers it, and the ways it still lies to you: two remote fields, sparse classifiers, provenance mix, and the archive tail.
7 min read
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.
8 min read