All posts
Insights

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.

HyperJobs Team8 min read

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.

BASH
curl "…/v1/jobs?taxonomies=Software,Healthcare"   # → the union: health-tech and everything beside it

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

SyntaxMeans
Between filters?skill=rust&has_salary=trueAND — both conditions hold
Inside a filter?seniority=Director,ManagementOR — either value matches
Inside skill — the exception?skill=rust,kubernetesAND — 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%
Per-field fill rates — live at /v1/meta → coverage_pct. Each AND on a sparse field silently discards everything that field never classified.

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:

BASH
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:

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