OQL API
Note: The OpenAlex Query Language is in alpha. It may change without warning — build against it at your own risk, and tell us what you think.
Everything OQL does is available over plain HTTP — no GUI required. There are two endpoint families, and they divide cleanly:
- Execute — send a query, get entity results back. Lives at the API root:
GET /?oql=…,GET /?oqo=…, orPOST /with a JSON body. - Translate — convert a query between its three forms (OQL text, OQO JSON, classic
URL) and validate it, without running it. Lives at
/query.
Every response also carries meta.x_query, which echoes your query back in all three
forms — so any result you get is also a lesson in how to write the next query.
Every example on this page runs on production today. Counts are live and will drift.
The 30-second version
# Run an OQL query (the API root, not /works — the query names its own entity):
curl -G "https://api.openalex.org/" --data-urlencode "oql=works where year is (2020)"
# Translate a query between forms without running it:
curl "https://api.openalex.org/query/oql/works%20where%20year%20is%20(2020)"
# Long query? POST it — no length limit:
curl -X POST "https://api.openalex.org/" \
-H "Content-Type: application/json" \
-d '{"oql": "works where year is (2020)"}'
The rest of this page is detail: each surface, its parameters, its response shape, and its errors.
The three query formats
Every query can be written three ways. They share one meaning but differ in syntax and in how much they can express:
| Format | Looks like | Reach for it when |
|---|---|---|
| Classic URL | /works?filter=publication_year:2020 |
you want backwards compatibility with the API you already know |
| OQL | works where year is (2020) |
you’re writing most applications — it reads and writes like a sentence |
| OQO | {"get_rows": "works", "filter_rows": […]} |
you’re building queries from a program or an agent — it’s JSON with a concrete, validatable schema |
How they relate:
- OQL and OQO are two faces of the same thing. Every OQL query has exactly one OQO and vice versa — same semantics, different syntax. OQO is the canonical internal representation: everything you send, in any format, is compiled to an OQO before it runs.
- The classic URL is less expressive. Every URL maps to a corresponding OQL and OQO, but not the reverse — there are many OQL/OQO queries (rich boolean nesting, proximity, grouping) that no classic URL can express. That’s deliberate: OQL/OQO widen the space of questions you can ask, so you can say exactly what you mean with more power and nuance.
So: use the classic URL for legacy compatibility, OQL for most applications, and
OQO when an agent or program needs a JSON schema to validate against. The
/query endpoint converts freely between all
three, and every result echoes all three back in meta.x_query.
Running a query
GET /?oql=
Put a URL-encoded OQL string in the oql parameter at the API root:
https://api.openalex.org/?oql=works where year is (2020)
curl -G "https://api.openalex.org/" \
--data-urlencode "oql=works where title has (cancer) and year >= (2020)" \
--data-urlencode "per-page=5"
Two things to know:
- The root, not
/works. An OQL query names its own entity (works where …,authors where …), so there’s one execution endpoint for all entities.GET /works?oql=…is an error (400, “oql is not a valid parameter”). - The response is the standard envelope — the same
{meta, results}(or{meta, group_by}) you get from/works?filter=…, with the same entity objects. Existing client code doesn’t need to change.
The paging parameters work alongside oql:
| Parameter | Works with ?oql= |
Notes |
|---|---|---|
per-page, page |
yes | basic paging |
cursor |
yes | deep paging; start with cursor=*, follow meta.next_cursor |
sort |
yes | classic sort=column:direction (comma-separated for tiebreakers), e.g. sort=cited_by_count:desc |
select |
yes | classic select=field,field to project a subset of fields |
api_key |
yes | or send Authorization: Bearer <key> (see Auth below) |
Sorting, field selection, and paging are view parameters, not part of the query
(the #661 query/view split): the OQO/OQL describes which rows, and sort, select,
page, per-page, and cursor travel as siblings of it — the classic query-string
parameters above, on either verb, or top-level body keys on POST (below). A sibling always
wins over a value still embedded inside an OQO (the deprecated pre-#661 shape, accepted
transitionally).
Grouping is part of the language — write it in the query:
curl -G "https://api.openalex.org/" \
--data-urlencode "oql=works where year is (2020) group by type"
…and the response comes back with a group_by array instead of results.
GET /?oqo=
OQO (the OpenAlex Query Object) is the JSON form of the same query — what OQL compiles to,
and the natural form when a program is building queries. Send it URL-encoded in the oqo
parameter, again at the root:
curl -G "https://api.openalex.org/" \
--data-urlencode 'oqo={"get_rows": "works", "filter_rows": [{"column_id": "publication_year", "value": 2020}]}' \
--data-urlencode "per-page=1"
The OQO carries the query itself — filters, grouping, and sampling (sample/seed
live inside the object; a sampled corpus is a different row set). Sorting, projection,
and paging are not in the object: put them in the URL parameters as above. See
“The OQO object” below for the shape.
POST /
GET puts the whole query in the request line, which is capped at 8,190 bytes — a big
Boolean query can blow past that. POST / takes the same query in a JSON body, with no
length limit:
curl -X POST "https://api.openalex.org/?per-page=5" \
-H "Content-Type: application/json" \
-d '{"oql": "works where year is (2020)"}'
or with an OQO, view params riding as sibling body keys:
curl -X POST "https://api.openalex.org/" \
-H "Content-Type: application/json" \
-d '{"oqo": {"get_rows": "works",
"filter_rows": [{"column_id": "publication_year", "value": 2020}]},
"sort": "cited_by_count:desc",
"select": "id,display_name,cited_by_count",
"per_page": 5}'
Rules of the road:
- The body is a JSON object with exactly one of
"oql"or"oqo", plus optionally the sibling view paramssort/select/page/per_page/cursor. Any other top-level key is a 400 (invalid_body), and so is sending bothoqlandoqo.Content-Type: application/jsonis required (without it: 400,invalid_body). - Sibling view params use the same classic syntax as the query-string form:
sortis a string like"cited_by_count:desc,display_name",selecta comma-string (a JSON list of column names also works in the body),page/per_pageintegers,cursora string. Body siblings win over query-string siblings, which win over anything still embedded in the OQO. sample/seedbelong inside the OQO — they change which rows you get, not how they’re presented;{"oql": "…", "sample": 5}is rejected.
Errors
A query that doesn’t parse or validate returns 400 with a structured validation
object — never a silent empty result:
curl -G "https://api.openalex.org/" --data-urlencode "oql=works where yearx is (2020)"
{
"validation": {
"valid": false,
"errors": [
{
"type": "parse_error",
"message": "unknown field \"yearx\" Fix: check the field name against the properties registry",
"position": 12,
"context": "works where yearx is (2020)",
"location": null
}
],
"warnings": []
}
}
type is one of parse_error, bad_request, invalid_entity, invalid_body. Parse
errors include the character position and the offending context, and the message
usually ends with a concrete Fix:.
Auth and cost
No API key is required — these endpoints follow the same rules as the rest of the API: a
free-account key raises your daily credit budget, and a query here costs the same credits
as its classic-URL equivalent (the response’s meta.cost_usd shows what each call cost).
Authenticate with either form:
curl -G "https://api.openalex.org/" \
-H "Authorization: Bearer YOUR_API_KEY" \
--data-urlencode "oql=works where year is (2020)"
or append &api_key=YOUR_API_KEY.
Translating a query: the /query endpoint
The same query has three interchangeable forms (see The three query formats above for when to use which):
| Form | Looks like | Best for |
|---|---|---|
oql |
works where year is (2020) |
humans reading and writing |
oqo |
{"get_rows": "works", "filter_rows": […]} |
programs and agents building queries |
oxurl |
/works?filter=publication_year:2020 |
the classic API you already know |
/query converts any form into all the others, and validates it — without executing
anything (it never touches the search index, and costs nothing). It’s the machinery
behind the GUI’s OQL tab, and it’s how a tool of your own can offer OQL input or output.
Because OQL and OQO are equally expressive, translating between them always round-trips
cleanly. Translating into oxurl can fail, though: the classic URL grammar can’t
represent every OQL/OQO query (deeply nested booleans, proximity search, some grouping). When
that happens /query returns the oql/oqo/validation fields and sets oxurl to null
with a note — the query is still perfectly valid and runnable, it just has no URL spelling.
GET /query/oql, /query/oqo, /query/oxurl
Put the query in the path (URL-encoded or plain):
curl "https://api.openalex.org/query/oql/works%20where%20year%20is%20(2020)"
curl "https://api.openalex.org/query/oxurl/works?filter=publication_year:2020"
curl "https://api.openalex.org/query/oqo/%7B%22get_rows%22%3A%22works%22%7D"
POST /query
Same 8,190-byte request-line cap as above; same escape hatch. The body takes exactly one of the three forms:
curl -X POST "https://api.openalex.org/query" \
-H "Content-Type: application/json" \
-d '{"oxurl": "/works?filter=publication_year:2020"}'
The translation response
Every /query translation returns the same six fields:
{
"oxurl": "/works?filter=publication_year:2020",
"oql": "works where year is (2020)",
"oql_oneline": "works where year is (2020)",
"oql_render_v2": { "…": "…" },
"oqo": {
"get_rows": "works",
"filter_rows": [{"column_id": "publication_year", "value": 2020}]
},
"validation": {"valid": true, "errors": [], "warnings": []}
}
| Field | What it is |
|---|---|
oxurl |
the classic-URL form — null when the query can’t be expressed as one (e.g. nested boolean logic the URL syntax can’t nest) |
oql |
the canonical OQL rendering (line-wrapped for readability on big queries) |
oql_oneline |
the same rendering forced onto one line |
oql_render_v2 |
a rich render tree (every token with its role and metadata) — for building editors and syntax highlighters; most clients can ignore it |
oqo |
the canonical OQO — parse any form, read this one |
validation |
{valid, errors, warnings}, same shape as execution errors above |
Bad input gets a 400 with the same validation object as the execution endpoints. A query
that parses but fails validation returns 400 with the oqo included, so you can see what
it understood.
Reading results: meta.x_query
Every list or group response — including from classic /works?filter=… calls —
carries an x_query object in meta, echoing the query in all three forms:
curl "https://api.openalex.org/works?filter=publication_year:2020&per-page=1"
"meta": {
"count": 11446647,
"x_query": {
"oql": "works where year is (2020)",
"oqo": {"get_rows": "works", "filter_rows": [{"column_id": "publication_year", "value": 2020}], "per_page": 1},
"url": "/works?filter=publication_year:2020&per_page=1"
}
}
What it’s for:
- Learn OQL from queries you already have — hit any classic URL and read the
oqlfield back. - Round-trip — feed
x_query.oqoback toPOST /(orx_query.oqlto?oql=) and get the same results; modify it programmatically along the way. - Interoperate — a tool that stores
x_query.oqocan rebuild the query in whichever surface it wants: OQL text, a classic URL, or the GUI.
url is null when the query has no classic-URL equivalent. On classic calls the
translation is best-effort — a handful of exotic parameter combinations don’t round-trip.
The
x_prefix means what it usually does: this field is young and its details may still shift. The three-forms concept is stable; treat exact field-level details as alpha, like the rest of OQL.
The OQO object
The full, formal shape is the JSON Schema on the OQO schema page (served at
/query/spec/oqo). The short version — top level:
| Key | Type | Meaning |
|---|---|---|
get_rows |
string, required | the entity to return: works, authors, institutions, … |
filter_rows |
array of filter nodes | the filters; top-level entries are AND-ed |
corpus |
core | expansion | all |
which works corpus (works only; default core) |
group_by |
array of {column_id} |
group results into buckets |
sample |
int | random sample of n results (optional seed, a string, for reproducibility) |
That’s the whole object (#661): sorting, projection, and paging are sibling request
params (sort, select, page, per_page, cursor — see “Rules of the road”
above), not OQO keys.
A filter node is either a leaf or a branch:
{"column_id": "publication_year", "value": 2020, "operator": ">="}
{"join": "or", "filters": [
{"column_id": "open_access.is_oa", "value": true},
{"column_id": "type", "value": "review"}
]}
- A leaf is
{column_id, value}plus optionaloperator(defaultis; also>,>=,<,<=,hasfor text search,in collection) andis_negated: truefor negation. - A branch is
{join: "and"|"or", filters: […]}, nesting to any depth — this is where OQO exceeds the classic URL syntax, which can’t nest.
Putting it together — “highly-cited recent works about cancer that are OA or reviews, top-cited first”:
curl -X POST "https://api.openalex.org/" \
-H "Content-Type: application/json" \
-d '{"oqo": {
"get_rows": "works",
"filter_rows": [
{"column_id": "default.search", "value": "cancer", "operator": "has"},
{"column_id": "publication_year", "value": 2020, "operator": ">="},
{"join": "or", "filters": [
{"column_id": "open_access.is_oa", "value": true},
{"column_id": "type", "value": "review"}
]}
],
"sort_by": [{"column_id": "cited_by_count", "direction": "desc"}],
"select": ["id", "display_name", "cited_by_count"],
"per_page": 5
}}'
Column ids are the machine names from the properties registry — list them at
https://api.openalex.org/properties (or per entity: /properties/works). The OQL name
and the column id often differ (year ↔ publication_year); when in doubt, write the
query in OQL, translate it, and read the oqo.
Related endpoints
| Endpoint | What it does |
|---|---|
GET /validate?q=<oql> |
lint an OQL string; always 200; returns rich diagnostics with stable error codes, spans, and fix-its — built for editors |
GET /parse-context?q=<oql>&pos=<n> |
what the grammar expects at a cursor position — built for autocomplete |
GET /properties, /properties/<entity> |
the registry of every queryable column, its operators, and its OQL name |
GET /query/spec |
index of the spec artifacts (cheat sheet, guide, spec, OQO schema, grammar) that power these docs pages |
And the rest of the story lives in the sibling pages here under /query: the Cheat
sheet and Guide teach the OQL language itself, the Spec is the normative
reference, and the OQO schema page renders the full JSON Schema.
OQL and its API are in alpha and under active development — tell us what’s confusing, what’s missing, and what you wish you could do.