PRQL queries

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 transformlowers into
from sq_nullsFROM sq_nulls
filter price > 15WHERE price > 15
sort {price} / sort {-price}ORDER BY price / ORDER BY price DESC
take 2LIMIT 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 x then 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.
  • aggregate takes the block form — aggregate { n = count this }.
  • Compound conditions spell && and ||. The word spellings (and / or) and in […] lists do not parse with this pin — the compiler answers a positioned prql_error; chain filter stages 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: a derive feeding a later stage (from x | derive bumped = price + 1 | filter bumped > 40), and a trailing select that drops a sort column (… | sort {-price} | take 2 | select {_key}) — prqlc wraps both to preserve semantics. Keep every sort column in the final select, or select before sort, or skip the select.
  • sum — PRQL defines sum over an empty input as 0, so prqlc emits COALESCE(SUM(price), 0), which is not a bare aggregate; the SQL frontend rejects it positioned. average, min and max emit 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.

Calendar October 6, 2026
Edit Edit this page