PRQL queries #
POST /_prql accepts
PRQL pipeline text in the
same request shape as POST /_sql — query, plus cursor, params,
snapshot_id and timeout riding alongside exactly as they do for SQL.
The statement is compiled by a pinned prqlc and executed as its
equivalent SQL through the identical pipeline, so cursors, snapshots,
timeouts and the response envelope behave the same
(see
SQL & PRQL).
A cursor walk resubmits the SAME PRQL text plus the cursor object; the compiled statement is cached by text, so the walk compiles once.
Pipelines and the SQL they lower into #
Every pipeline compiles into ONE SELECT statement inside the SQL subset — the transforms map onto clauses:
| PRQL transform | lowers into |
|---|---|
from sq_nulls | FROM sq_nulls |
filter price > 15 | WHERE price > 15 |
sort {price} / sort {-price} | ORDER BY price / ORDER BY price DESC |
take 2 | LIMIT 2 |
select {_key, price} | the SELECT list |
group {tag} ( aggregate { … } ) | GROUP BY tag with the aggregate list |
The canonical pipeline, with the SQL it compiles to:
POST /_prql
{ "query": "from sq_nulls | filter price > 15 | sort {-price} | take 2 | select {_key, price}" }
SELECT _key, price FROM sq_nulls WHERE price > 15 ORDER BY price DESC LIMIT 2
No select at all is SELECT * — the lane’s dynamic column expansion:
POST /_prql
{ "query": "from sq_nulls | filter tag == 't1' | take 2" }
select renames with = and may sit before sort (then the sort uses
the new names):
POST /_prql
{ "query": "from sq_nulls | select {k = _key, p = price} | sort {-p} | take 2" }
The idiomatic multi-line spelling — a leading | starts each stage —
normalizes into the single-line pipeline before compiling, with error
positions mapped back onto your text:
from sq_nulls
| filter price > 15
| sort {-price}
| take 2
| select {_key, price}
Compound conditions: the pinned compiler takes && and || (see the
syntax notes below), and chained filter stages AND together. != null
tests existence with SQL’s three-valued semantics — a document without
the field drops, on either polarity:
POST /_prql
{ "query": "from sq_nulls | filter price > 15 && tag != 't0' | take 3" }
POST /_prql
{ "query": "from sq_nulls | filter price > 15 | filter tag != 't0' | take 3" }
POST /_prql
{ "query": "from sq_nulls | filter price != null | take 2" }
Grouped aggregation and cursors #
The aggregate BLOCK form carries the aggregates: count this lowers to
COUNT(*), and average/min/max lower to AVG/MIN/MAX
(sum does not make it through — see the boundaries below):
POST /_prql
{ "query": "from sq_nulls | group {tag} ( aggregate { n = count this, avg_price = average price, lo = min price, hi = max price } ) | sort {tag}" }
Grouped queries ride the composite keyset cursor exactly like SQL —
sort {tag} ascending over the group key with a take is the
addressable order:
POST /_prql
{ "query": "from sq_nulls | group {tag} ( aggregate { n = count this } ) | sort {tag} | take 2" }
The response carries the cursor object; resubmit the SAME text plus it
for page two, and the walk retires on cursor: null
(see
SQL & PRQL for the envelope).
The pinned compiler’s shapes #
prqlc is version-pinned (0.13.14) and its emitted SQL must stay inside
the SQL subset above. Syntax notes for this pin:
- Pipelines are single-line to the pinned compiler, but the idiomatic
leading-pipe continuation (
from xthen lines starting with|) is accepted: the lane normalizes it into the single-line pipeline before compiling, and compile-error positions map back onto the submitted text. A continuation joins only onto a code line — after a blank line or a comment it stays put and the compiler’s own positioned error answers. The parenthesized form (from x\n( filter … | take 3 )) keeps working. aggregatetakes the block form —aggregate { n = count this }.- Compound conditions spell
&&and||. The word spellings (and/or) andin […]lists do not parse with this pin — the compiler answers a positionedprql_error; chainfilterstages or write the comparisons with&&instead.
s-strings are rejected #
PRQL s-strings (s"SELECT …") escape into arbitrary SQL that the subset
cannot guarantee, so they are rejected outright with a source-positioned
error — before compilation:
{
"error": {
"type": "prql_error",
"reason": "s-strings are not allowed — they escape into arbitrary SQL (OQ-3); lower the logic into PRQL or submit the SQL directly to /_sql",
"position": { "line": 1, "column": 24 }
}
}
The same text inside a comment or a plain string literal never trips the scan.
Fail-closed boundaries #
Boundaries answer a positioned error — never a silently different answer:
- A pipeline that lowers into a CTE (
WITH table_0 AS …) is outside the SQL subset. Two shapes trigger it: aderivefeeding a later stage (from x | derive bumped = price + 1 | filter bumped > 40), and a trailingselectthat drops asortcolumn (… | sort {-price} | take 2 | select {_key}) — prqlc wraps both to preserve semantics. Keep everysortcolumn in the finalselect, orselectbeforesort, or skip theselect. sum— PRQL definessumover an empty input as 0, soprqlcemitsCOALESCE(SUM(price), 0), which is not a bare aggregate; the SQL frontend rejects it positioned.average,minandmaxemit bare aggregates and pass; for a sum, submit the SQL directly to/_sql.
Both answer the shared error contract: compile diagnostics map to
type: "prql_error" with the position on the PRQL source (hints join
the reason); a shape that compiles but leaves the subset answers
type: "sql_error" from the SQL frontend, positioned onto your text.
PRQL has no $1 surface of its own, so a params array fails closed
through the SQL lane exactly like an unused SQL param.