The question arrives in two forms. Why not just use GraphQL? — asked by someone choosing between them. And could this become a JSON-Schema-native alternative to GraphQL? — asked by someone who has noticed that the two overlap somewhere. This document answers both, starting from the observation that makes both answerable: they overlap much less than the question assumes.
GraphQL bundles:
- A type system with mandatory introspection. SDL, plus a query every server must answer that returns its own schema.
- Client-specified projection. The response contains the fields the client asked for and nothing else. This is the feature GraphQL is adopted for.
- Traversal. Nested selections are joins, executed by resolvers.
- A transport and execution contract. One endpoint, variables, operations, an
errorsarray alongside partialdata, subscriptions.
Filtering is not on that list. The GraphQL specification says nothing about where. Every
server invents its own input objects — Hasura's _eq/_in, Prisma's equals/contains,
Postgraphile, Dgraph — with no shared vocabulary, no shared null semantics, and no portability
between them. A client that knows GraphQL does not thereby know how to filter.
That is the gap this specification occupies. JSON:API
has the same hole and says so explicitly: it reserves the filter query parameter and declines to
define its contents. MCP tool definitions have it too — an agent is handed an inputSchema and
left to infer a query syntax from prose.
So the first answer is that this is not GraphQL's competitor; it is the piece GraphQL left
out. A GraphQL server could adopt the grammar as the body of its where argument tomorrow and
lose nothing.
The same request, filtering pets and taking three fields.
GraphQL — projection is the point; the filter vocabulary is the server's invention:
query {
pets(where: { _and: [
{ status: { _eq: "available" } },
{ born: { _gte: "2020-01-01" } }
]}, limit: 50) {
id
name
shelter { city }
}
}OpenPredicate — the filter is standard and schema-validated; projection, ordering and paging belong to the enclosing body, where each API defines them:
QUERY /pets HTTP/1.1
Content-Type: application/json{
"filter": {
"status": "available",
"born": { "$gte": "2020-01-01" }
},
"fields": ["id", "name", "shelter.city"],
"limit": 50
}The differences that matter are not the syntax. In the GraphQL version the response type is known
statically from the selection set, and the filter is unportable. In the second the filter is
portable and machine-checkable, and fields is whatever that API decided it should be.
Stated plainly, because these are the reasons to choose GraphQL and they are good ones.
| Response shape follows the request | The single hardest thing to reproduce. See §5. |
| One round trip across many resources | A GraphQL document fetches pets, their shelters and their vaccinations together. Per-resource search endpoints do not. |
| One mandatory introspection format | Every GraphQL server answers the same introspection query, which is why GraphiQL, codegen and federation work everywhere. SPEC.md §2.2's capability document is RECOMMENDED, not required, and lives at an unspecified URL. |
| Federation | Composing one graph from many services is a solved, productised problem there and an unsolved one here. |
| Subscriptions | Out of scope entirely. |
| Mutations | Out of scope entirely; this is a predicate language. |
| HTTP survives | Cache-Control and ETags apply, QUERY and GET stay safe and cacheable, status codes mean what they say, and a rejected filter is a 400 naming which of five conditions failed, with an RFC 6901 pointer at the offending clause, in whatever error format the API already uses (SPEC.md §8). GraphQL's single POST endpoint forfeits intermediary caching; application/graphql-response+json recovers status codes but not cache keys. |
| Null semantics are specified | SPEC.md §4.1 and §4.2 pin down three-valued logic and the missing-versus-null distinction. GraphQL's null propagation is its most notorious sharp edge, and its filter semantics are per-vendor folklore. |
| Complexity limits are normative | SPEC.md §7 bounds depth, clause count, set length and regex execution, and requires rejection rather than truncation. GraphQL query cost is left to each shop to solve. |
| Honest partial implementations | Profiles (SPEC.md §2.1) let a server advertise the subset it implements and reject the rest, instead of mistranslating it. |
| Incremental adoption | One endpoint can adopt the filter grammar without the rest of the API changing. GraphQL is a parallel stack. |
| It is already in the toolchains | JSON Schema is what OpenAPI 3.1 speaks and what an MCP inputSchema is. No new parser, client or IDE plugin. |
That last row is worth more in 2026 than it was in 2016. An LLM agent gains little from client-specified projection — it did not want the other fields anyway — and a great deal from a grammar it can be handed, validated against, and corrected by in one round trip. See README §Exposing search to an agent.
Taking the ambition seriously, layer by layer. Each of these is independently useful, which is the point: the reason to build this way rather than adopt GraphQL is that adopters can take one layer and leave the rest.
| Layer | Status | The hard part |
|---|---|---|
| filter | this specification | done |
| capabilities | SPEC.md §2.2, RECOMMENDED | making it mandatory and locating it, so tooling can rely on it |
| select | not started | deriving the response schema from a runtime selection set |
| expand / link | not started | JSON Schema has no relationship vocabulary; JSON Hyper-Schema tried and stalled after draft-07 |
| order / page | not started | little novelty; cursor opacity is the only real decision |
| envelope + errors | SPEC.md §8 | done for filters; would need extending per layer |
The load-bearing difficulty is projection typing. GraphQL's trick is that the client's query document is itself the input to type derivation — that is what Relay and graphql-codegen consume. JSON Schema cannot express "the response shape is a function of this request's selection set". There are two workable answers and they are not exclusive:
- Normatively: the response schema is the resource schema with every non-selected property optional. A selection then guarantees presence, not typing — the same bargain OData and JSON:API's sparse fieldsets strike.
- In tooling: derive the exact response schema client-side from
resource schema ∩ selection. This is codegen, and it is what GraphQL users are actually running anyway.
A relationship vocabulary is the second obstacle, and the precedent is discouraging. JSON Hyper-Schema was the attempt and it did not reach 2019-09. Anything here would have to be new work, and cross-resource traversal is the point at which the N+1 problem, authorisation per hop and query cost all arrive at once — everything GraphQL servers spend their complexity budget on.
The gap that most weakens the comparison today is that the published grammar shares one
Constraint across every field, so it can validate that {"status": "Available"} is well-formed
but not that "Available" is outside status's domain. GraphQL does not have this problem: it
generates StringFilter, StatusEnumFilter and so on per field, so the equivalent mistake is a
compile-time error.
tools/generate-filter-schema.mjs closes it the same way,
from the resource's own JSON Schema — see
README §Generating a per-resource filter schema.
data schema → filter schema is the same pipeline as GraphQL's SDL → input types, with JSON
Schema as the source of truth instead of SDL. Extending that generator to emit a select schema
and a response schema from the same input is what would turn the phrase "JSON-Schema-native
alternative to GraphQL" into an architecture rather than a slogan.
Anyone building this should know what has already been tried.
| OData | The dream, already attempted and standardised (OASIS, ISO/IEC 20802). $filter, $select, $expand, $orderby, $top/$skip, $metadata. Worth studying for why it did not displace GraphQL: a string-encoded $filter no schema can validate — precisely the defect this grammar fixes — an enormous surface, and CSDL metadata few enjoyed. |
| JSON:API | Sparse fieldsets and include are a working design for the select and expand layers. Its filter is deliberately unspecified. |
| OGC CQL2 | Direct prior art for the predicate itself, including a JSON encoding. |
| MongoDB query language | The obvious syntactic ancestor of $eq/$in. The divergences here are deliberate: no implicit coercion, specified three-valued logic, $in as whole-value comparison with element access spelled $some, and $elemMatch renamed to $some beside a $every MongoDB has no equivalent for. |
| SQL++ / N1QL | The closest existing thing to this specification's ambition — a SQL-like language over JSON, backed by an academic formal semantics rather than prose alone. Its ANY/EVERY/SATISFIES land on the same vacuous-truth answer as $some/$every (§5.8): EVERY over an empty collection is TRUE, independently arrived at. Its NULL/MISSING distinction is arguably four-valued where §4.1 is three — NOT NULL stays NULL and NOT MISSING stays MISSING, so the cause of UNKNOWN survives negation, where here it has to be recovered separately via $exists/$isNull (§4.2). Its WITHIN (unknown-depth descent) and array slicing are exactly what §3.2's dropped wildcard segment declines to add, for the same reason. What it confirms is that the semantics here are not novel — SQL++ reached most of the same answers — and that what's actually missing from the field is not another dialect but an open governance model: it is a single vendor's language, unlike this one. |
| SCIM filters, AIP-160 | String-encoded filter grammars, with the same unvalidatable-parameter problem as OData. |
| JSONPath (RFC 9535) | A standardised path grammar. SPEC.md §3.2 uses a deliberately smaller one — smaller again since v0.4.0 dropped the [*] wildcard segment in favour of explicit quantifiers, which narrows the divergence to the non-wildcard subset. |
| JSON Hyper-Schema | The cautionary tale for the expand layer. |
Hasura / Prisma / Postgraphile where |
The de facto GraphQL filter dialects. Between them they are the closest thing to an existing standard, and none of them is one. |
- Choose GraphQL if clients need to shape their own responses, if one round trip across many resources is a requirement, or if you need federation or subscriptions.
- Choose this if you have HTTP endpoints that already work and want their filters to be consistent, validated in CI, typed in generated clients, and usable by an agent — without adopting a parallel stack.
- Choose both if you run GraphQL: nothing stops the grammar being the body of your
whereargument, and it would make your filter vocabulary portable to the REST endpoints beside it.
The honest framing of the ambition: what is published here is one layer, and the most valuable one to standardise because it is the one everybody re-invents. Whether the layers above it get built is a separate question from whether this layer is worth adopting, and the answer to the second does not depend on the first.