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

Source: https://hyperjobs.io/blog/skills-that-dont-round-trip

*HyperJobs Team · 19 May 2026 · 8 min read*

A response hands you a skills array. Something like this — illustrative, but the
shape is exact:

```json
{ "skills": ["Machine Learning", "pytorch", "CI/CD"] }
```

You render all three as chips. A user clicks one. You send it straight back:

```bash
curl -G https://api.hyperjobs.io/v1/jobs \
  -H "Authorization: Bearer $HYPERJOBS_KEY" \
  --data-urlencode "skill=Machine Learning"
```

`200 OK`. `total: 0`. Click `pytorch` instead and you get rows. Same array, same
request shape, two different outcomes — and nothing in either response tells you
which one you're looking at.

The value the API showed you is not the value the API accepts. That is the whole
post.

## Skills are tokens, not text

Skills are stored as **canonical lower-case tokens with no spaces and no
punctuation**. Not free text, not the words that appeared in the posting — a
normalised token drawn from a fixed vocabulary. `Machine Learning` in a job
description becomes the token `machinelearning`, and that token is the only
string in the database.

So `?skill=` is a token match. Not a substring match. That is the opposite of
every other text filter on the same endpoint:

| Parameter | Matching | What that means |
| --- | --- | --- |
| `title=` | Substring | `java` finds `JavaScript Engineer` |
| `description=` | Substring | Same — it matches anywhere in the body |
| `skill=` | **Token** | `java` does **not** match `javascript` |

And the sharper edge of the same rule: **`skill=py` matches nothing at all.**
Not "few results" — zero. There is no prefix search on skills, no stemming, no
partial anything. You send a token or you send nothing.

Two filters on one endpoint with opposite matching modes is a genuine
inconsistency, and it is worth saying so rather than explaining it away. It is
also defensible in one direction: `skills` is a closed-ish vocabulary, and
substring-matching a vocabulary would make `java` silently return every
`javascript` posting — a superset you never asked for and can't detect. Exact
tokens make `skill=java` mean Java. The cost is that the filter is unforgiving in
exactly the way `title=` is forgiving, and the API [never returns a
400](/docs/errors), so being wrong looks identical to being right about an empty
niche.

## Two alias tables, pointing opposite ways

Here is why the token model isn't enough to explain the zero. There are two
translation layers, and they are not inverses of each other.

| | **Query aliases** | **Display aliases** |
| --- | --- | --- |
| Direction | Input — the value you send | Output — the `skills` array on the way out |
| When | Rewrites before the query runs | Formats a token for a human to read |
| Size | 8 entries | Not enumerated |
| Reverse mapping | n/a | **None** |

Display aliases are **one-way**. There is no reverse table, so a value the API
*rendered* is not necessarily a value the API will *resolve*. Feed it back and
you get an empty set that looks exactly like "no jobs match".

What makes this ship to production rather than getting caught in an afternoon is
that most tokens have no display alias at all. The [Job object](/docs/objects/job)
example comes back as `["typescript", "graphql", "postgresql"]` — raw tokens,
which round-trip perfectly. Your chip component works. It works for weeks. Then
someone clicks the one chip that got formatted.

## The round trip, documented

Straight from the [skills guide](/docs/guides/skills-and-taxonomies):

| You see | Actually stored as | `?skill=` with what you saw | `?skill=` with the token |
| --- | --- | --- | --- |
| `Machine Learning` | `machinelearning` | **0 rows** | 22,269 |
| `CI/CD` | `cicd` | **0 rows** | 4,178 |
| `Quality Assurance` | `qualityassurance` | **0 rows** | ✓ |
| `C++` | `cpp` | ✓ — input-aliased | ✓ |

Three of those four rows are the failure. The fourth is the exception, and the
exception is the confusing part.

<Bars
  caption="Rows behind five canonical tokens. Every bar is a string the filter accepts. The strings the API showed you — `Machine Learning`, `CI/CD` — return zero for the same postings."
  items={[
    { label: "machinelearning", value: 22269, display: "22,269" },
    { label: "cpp", value: 9586, display: "9,586" },
    { label: "csharp", value: 6309, display: "6,309" },
    { label: "dotnet", value: 4912, display: "4,912" },
    { label: "cicd", value: 4178, display: "4,178" },
  ]}
/>

## Why `c++` works and `Machine Learning` doesn't

`?skill=c++` returns 9,586 rows. `?skill=c#` returns 6,309. `?skill=.net`
returns 4,912. `?skill=node.js` resolves too. All punctuated, all fine — which
looks like evidence that the API is tolerant about punctuation.

It isn't. **The punctuated forms are not stored.** `c++` as a stored token is 0
rows. `c#` is 0. Those queries work because the *input* alias rewrites them to
`cpp`, `csharp`, `dotnet` and `nodejs` before the query ever runs. The token
model is intact; a table of eight entries is papering over four of its corners.

That is the trap, stated precisely: **only those eight query-alias entries
survive the round trip. Everything else that gets display-formatted does not.**
`C++` works and `CI/CD` doesn't, and there is nothing about the two strings that
predicts which is which. You are pattern-matching on a lookup table you cannot
see.

## The heuristic, and what it isn't

If a displayed skill contains a space or a slash, strip them and lower-case it
before filtering:

```js
// Turns a displayed skill back into a queryable token.
const toToken = (displayed) => displayed.replace(/[\s/]/g, "").toLowerCase();

toToken("Machine Learning"); // → "machinelearning"
toToken("CI/CD");            // → "cicd"
```

Be clear about what that is. **It is a heuristic, not the inverse function — no
such function exists.** Display aliasing has no reverse mapping, so there is no
correct way to recover a token from a rendered string; there is only a rule that
covers the observed failures. Spaces and slashes are the whole observed failure
mode, so the rule earns its place. It is still a guess with a good track record,
not a guarantee.

The robust version of your chip component doesn't call `toToken` at click time at
all. It keeps the token it filtered *with*, renders the string the API sent, and
never asks the display string to do a second job. Round-tripping through the UI
is the bug; carrying the token alongside it is the fix.

## Picking a classifier, and not picking three

Skills are one of three ways to ask "what kind of job is this", and they trade
off against each other:

| Use | Vocabulary | Coverage | Cost |
| --- | --- | --- | --- |
| `taxonomies` | 28 fixed Title-Case values | 64% | Coarse; a job family, not a capability |
| `skill` | Open, canonical tokens | 84% | Specific, but tokens only |
| `title` | Free text | 100% | Substring, no stemming, noisy |

`skill` is the highest-signal option with the best coverage of the three, so
start there for anything technology-shaped. Use `taxonomies` to scope a broad
query to a sector, not to find things — a third of postings carry no taxonomy and
are invisible to it.

**Don't reach for all three defensively.** Coverage doesn't stack, it
intersects. Every classifier you add is another AND, and each one silently drops
the postings it never classified. 84%, 64% and 100% do not combine into
reassurance; they combine into a much smaller set than you expect, with nothing
in the response to say so.

The same applies inside `skill` itself — it's the one filter where comma still
means AND:

```bash
# Tagged BOTH python AND aws. Not "either".
curl "…/v1/jobs?skill=python,aws"
```

Every other multi-value filter treats commas as [OR](/docs/guides/filtering),
but skills compose requirements, so `skill` intersects. For a union *across*
skills, run one request per value and merge on `id` — N values costs N requests
against your [rate limit](/docs/rate-limits).

## The diagnostic

When a skill filter returns a number you don't believe, ask in this order. Did
the displayed string contain a space or a slash — that's `toToken`. Is it
punctuated and *not* one of the eight — then it was never going to work. Did you
comma-join values expecting OR — on `skill`, uniquely, you asked for AND. And if
`total` didn't move at all when you added the filter, [check what the filter
actually tests](/docs/guides/filtering) — `skill` is an open vocabulary, so a
token nothing carries is a silent zero, not a `400`.

The token table, the alias tables and the full round-trip rules live in
[skills & taxonomies](/docs/guides/skills-and-taxonomies).
