SQL & PRQL #
Pizza accepts SQL-shaped queries over collections through two text frontends. Both lower into the same execution pipeline as the search DSL — same authz, same lanes, same engine — and answer with a frozen tabular envelope ready for table rendering:
POST /_sql— a SQL-92-shaped subset with Pizza extension functions for full-text, vectors and relevance.POST /_prql— PRQL pipelines, compiled by a pinnedprqlcand executed as the equivalent SQL (see PRQL queries).
The same question in both frontends — the PRQL pipeline compiles to the equivalent SQL and rides the identical pipeline, so both answer the same frozen envelope:
POST /_sql
{ "query": "SELECT _key, price FROM sq_nulls WHERE price > 15 ORDER BY price DESC LIMIT 2" }
POST /_prql
{ "query": "from sq_nulls | filter price > 15 | sort {-price} | take 2 | select {_key, price}" }
The transform-by-transform catalog (filter, sort, take, select,
group) with worked examples and the pinned compiler’s boundaries lives
in
PRQL queries.
Try both live in the web console (⌘/Ctrl+Shift+O from anywhere) — bare
SELECT/from statement blocks run with keyword and schema completion,
$1 params and PIT snapshot pinning behind the sql options popover, and
keyset cursor paging via the table’s Next page button. In the docs, every
tutorial lesson pairs its DSL examples with
the equivalent SQL statement in an in-browser playground, and the
playground page carries the full interactive
surface with its DSL⇄SQL toggle.
The clause-by-clause walk with runnable examples lives in SQL syntax & examples.
Request and response #
POST /_sql
{
"query": "SELECT _key, tag, price FROM sq_nulls WHERE price > $1 ORDER BY _key LIMIT 20",
"params": [10],
"snapshot_id": "pit-id-from-open",
"cursor": null
}
| Field | Meaning |
|---|---|
query | the statement text (SQL, or PRQL on /_prql) |
params | positional values for $1-style references — bound on the parsed tree, never spliced into text |
snapshot_id | a point-in-time id from POST /{targets}/_pit; per-request and never inherited — a cursor walk resubmits the SAME id on every page |
cursor | the opaque object a previous grouped page returned; resubmit the query unchanged plus this object |
timeout | per-request execution deadline ("5s", "500ms"); an unparseable value falls back to the node’s search.default_timeout_ms — same semantics as the DSL lane |
Statements are capped at 256 KiB — the surface is for
statements, not payloads; oversized requests fail with a 400 before any
parsing. Both /_sql and /_prql write into the same search-audit ring
as /_search (GET /_node/_local/search_audit), recording the source
text you submitted.
The response envelope is frozen:
{
"columns": [{ "name": "_key", "type": "string" }, { "name": "price", "type": "int" }],
"rows": [["n2", 20], ["n4", 40]],
"cursor": { "_k0": "n4" }
}
cursor is non-null only when the plan sorts grouped rows in the
composite order (every GROUP BY key ascending, or no ORDER BY) with a
LIMIT — that keyset walks the pages without overlap. OFFSET pagination
serves hit-lane pages only and cannot combine with cursors.
EXPLAIN <select> answers the LOWERING TRACE — the compiled plan as the
DSL a /_search would run, plus the would-be answer’s column labels.
Nothing executes:
{
"columns": ["tier", "COUNT(*)"],
"query": {
"query": { "match_all": {} },
"aggs": { "_g": { "terms": { "field": "tier", "size": 10000 } } },
"size": 0
}
}
A cursor on an EXPLAIN is rejected (it answers a plan, not pages).
What the SQL subset covers #
| Domain | Coverage |
|---|---|
| Projection | columns, *, expressions, AS aliases, CASE (searched, < ladders — as a GROUP BY key) |
| Filter | term / range / boolean / IN / BETWEEN / LIKE / IS [NOT] NULL with three-valued NULL logic (a missing field answers UNKNOWN to every polarity) |
| Aggregation | GROUP BY (columns, bucketing functions, CASE ladders), COUNT/SUM/AVG/MIN/MAX (incl. DISTINCT inside the aggregate), HAVING, composite cursor pagination |
| Bucketing | DATE_TRUNC(field, 'day'|'30m'|…) and HISTOGRAM(field, 50) as GROUP BY keys |
| Sorting & paging | ORDER BY over columns or SCORE() (keyset cursors) / LIMIT / OFFSET (hit pages) |
| Parameters | $1 positional values via the request params array |
| Snapshots | PIT pinning via snapshot_id, identical semantics to the DSL lane |
| Scalar functions | hit-lane projections and filters: string (UPPER LOWER LENGTH TRIM LTRIM RTRIM SUBSTRING CONCAT REPLACE LEFT RIGHT), math (ABS ROUND CEIL FLOOR SQRT POWER), date extractors (YEAR MONTH DAY_OF_MONTH DAY_OF_WEEK HOUR_OF_DAY MINUTE SECOND) |
| Pizza extensions | MATCH(field, 'text') (infix field MATCH 'text' too), MATCH_PHRASE(field, 'phrase'), FUZZY(field, 'term', 'AUTO'), KNN(vector, [..], k), QUERY('{…raw DSL…}'), SCORE(), HIGHLIGHT(field, pre, post) |
| FROM | one bare collection name in the default namespace |
Not in the subset today: JOIN across collections (planned), subqueries,
CTE (WITH), window functions, set operations, and SELECT DISTINCT
(dedupe with GROUP BY — COUNT(DISTINCT …) is supported). Statements
outside the subset fail closed with a source-positioned error — never a
silently different answer.
Errors #
Plan and compile errors answer 400 with the reason and a 1-based
position against the submitted text — the same contract on both
frontends, distinguished by type:
{
"error": {
"type": "sql_error",
"reason": "…",
"position": { "line": 1, "column": 24 },
"span": { "start": 23, "end": 25 }
}
}
/_prql compile failures carry "type": "prql_error" with the position
mapped onto the PRQL source. Statements are cached by text (parsed plans
on /_sql, compiled SQL on /_prql), so identical statements parse once.