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

Source: https://hyperjobs.io/blog/there-is-no-or

*HyperJobs Team · 2 June 2026 · 8 min read*

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

```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

| | 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](/docs/errors), 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.

<Bars caption="Per-field fill rates — live at /v1/meta → coverage_pct. Each AND on a sparse field silently discards everything that field never classified." max={100} items={[{ label: "country", value: 96.8, display: "96.8%" }, { label: "skills", value: 92.4, display: "92.4%" }, { label: "taxonomies", value: 90.3, display: "90.3%" }, { label: "education", value: 71.5, display: "71.5%" }, { label: "salary", value: 36.6, display: "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`](/docs/api-reference/meta), refreshed live, so these bars
date more gracefully than the ones they replaced) — it cannot publish the
correlations.

So the rule from [skills & taxonomies](/docs/guides/skills-and-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.

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

## 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](/docs/rate-limits) — 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`](/docs/guides/filtering)
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](/docs/guides/filtering),
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](/docs/guides/filtering).
