PartiQL: joins, subqueries & set operations

View .md

Single-table design answers the questions you knew about. DynoStudio answers the rest. The part of the dialect series where one statement compiles to multiple native requests. DynamoDB has no JOIN, can't run a subquery, and has no set operators — the studio composes all three from the reads you mastered in Targeted reads, key-aware and disclosed at every step. This is the page built for single-table designs, where related items reference each other through key templates.

You'll learn
  • Compose semi-joins and anti-joins with IN / NOT IN (SELECT …), and keep each off a Scan
  • Use a correlated subquery (1 + N) and keep every per-row run a Query
  • Join across tables five ways — INNER, LEFT, RIGHT, FULL, CROSS — and pick the shape by its cost
  • Combine result sets with UNION / INTERSECT / EXCEPT
  • Read the multi-request "Executes as" strip and respect the caps

IN / NOT IN (SELECT …): semi-joins & anti-joins

sql
-- outbox rows whose ids come from a DIFFERENT tableSELECT * FROM "stage.Outbox"WHERE pk IN (SELECT 'Outbox#{Id}' FROM "stage.Events"             WHERE EventType = 'Bounce' LIMIT 25)

DynamoDB can't run a subquery, so the studio executes two dependent requests: the subquery first (paginated) into a distinct value set, then the outer read — inlined into a native IN list when the set is small and scalar (≤ 100 values), or applied as a disclosed client-side page filter otherwise. An empty value set short-circuits: no outer read at all. When the IN attribute is the partition key — the common case, because that's the shape stored keys take — the inlined form executes as key lookups, one per value, not a Scan. Past the 100-value inline cap a partition-key set fans out into chunked pk IN (≤ 100) key-lookup batches (still no Scan); only a non-key IN past the cap falls back to the full-read page filter, and the strip says which — labelling the run Semi-join · 2 native requests.

The inner projection takes three shapes: a bare attribute (SELECT Id), SQL string concatenation (SELECT 'Outbox#' || Id||, never +), or a Kanject key template (SELECT 'Outbox#{Id}', multi-placeholder and transform-aware). Key templates are why this matters: stored keys are template-shaped, so bare-Id subqueries match nothing. Give the subquery a LIMIT — an unbounded Scan subquery draws an editor warning, because it runs in full before the outer read.

Render a key template Try typing
Bare id 42 matches nothing
'Customer#{CustomerId}' Customer#42 the stored key shape
Stored keys are template-shaped — a subquery projecting the bare id finds no rows; the template re-spells it into the key DynamoDB actually stores.
sql
-- anti-join: keep the rows the subquery did NOT produceSELECT * FROM "stage.Outbox"WHERE pk = 'Outbox#live'  AND sk NOT IN (SELECT 'Evt#{Id}' FROM "stage.Processed")

NOT IN (SELECT …) is the mirror — an anti-join (the strip labels it Anti-join · 2 native requests). The subquery still runs first, but the outer read keeps the rows whose attribute is absent from the value set. DynamoDB has no negated key condition, so NOT IN never inlines: the outer read is a Scan with a client-side exclusion filter unless another predicate keys itWHERE pk = 'X' AND sk NOT IN (SELECT …) stays a partition-scoped Query with the exclusion applied as a filter. Two semantics, both disclosed: a row whose attribute is absent or NULL is excluded (a missing value never satisfies NOT IN), and NULLs in the subquery are ignored rather than collapsing the whole result to empty — a friendlier departure from SQL's NOT IN NULL trap. An empty subquery set keeps every present-attribute row, so unlike IN there's no empty short-circuit.

Correlated subqueries (1 + N)

sql
-- the subquery references the outer row → 1 + N requestsSELECT * FROM "stage.Orders" AS oWHERE o.lineId IN (SELECT l.id FROM "stage.Lines" AS l WHERE l.orderId = o.id)

When the subquery references the outer row, it's correlated, and the execution inverts: drain the outer read first, then re-run the subquery once per outer row with the correlation value bound. The strip says Correlated semi-join · 1 + N requests; correlate on the partition key so each per-row run is a Query, not a Scan.

Any number of correlations work — inner.x = o.a AND inner.y < o.bequality or inequality alike, because each o.attr reference is simply bound to that row's value. The outer must be SELECT * (so every correlation attribute is present) and is capped at 500 rows, each of which re-runs the subquery; past that it refuses with the fix. An outer row missing a correlation value binds nothing, so its subquery set is empty (IN → excluded, NOT IN → kept). NOT IN composes here too — a correlated anti-join.

Key-aware joins

sql
SELECT o.orderId, o.total, c.emailFROM "stage.Orders" AS oLEFT JOIN "stage.Customers" AS cON c.pk = 'Customer#{CustomerId}'WHERE o.status = 'FAILED'

DynamoDB has no JOIN — the studio drains the left side (its WHERE and LIMIT ride along), then resolves the right side per left row through its key, four ways:

  • BatchGetItem when the ON conditions cover the full primary key — chunked at 100 keys, unprocessed keys drained.
  • One Query per distinct partition value when only the base partition key binds — a left row merges once per right match.
  • Query on a GSI when the ON binds a GSI's partition key instead of the base key — one Query per distinct value on that index; the strip names it (Query · <index>).
  • A refusal when the ON reaches neither the base key nor any GSI key — a materialized-view recommendation instead of an unsafe scan.

ON conditions are equalities, plain (o.customerId = c.customerId) or the Kanject key-template form (c.pk = 'Customer#{CustomerId}', the single-table workhorse, rendered per left row). An INNER join drops a left row that finds no right match; a LEFT [OUTER] join keeps it, with the right columns omitted. (RIGHT, FULL and CROSS take a different path — see below.) One JOIN per statement, explicit AS aliases, and projections name their columns (alias.attr [AS name]) because both sides may share attribute names — * is ambiguous and refused. WHERE filters the left side only.

Try it · Key-aware joinSample
Executes as2 request groups · drain → BatchGetItem
1 · Query stage.Orders Filter: status = 'FAILED' → 2 · BatchGetItem stage.Customers (key per left row)
The left side drains first (its WHERE rides along); the right side resolves per left row through its key — a BatchGetItem, not a Scan.
LEFT: an order whose customer row is missing is still kept, with c.email blank.
#o.orderIdo.totalc.email
1Order#5821142.50ada@acme.io
2Order#599088.00
Fetched 2 items in 23ms · 100% efficient · 3 RCU · 2 partitions

RIGHT, FULL & CROSS joins

sql
-- keep customers with no orders too (unmatched right rows)SELECT o.orderId, c.emailFROM "stage.Orders" AS oRIGHT JOIN "stage.Customers" AS c ON c.customerId = o.customerId-- every pairing, no ONSELECT a.sku, b.region FROM "stage.Skus" AS a CROSS JOIN "stage.Regions" AS b

These can't be served by probing the right side per left row — a RIGHT or FULL join has to surface right rows no left row reached, and CROSS pairs everything with everything. So the studio drains the entire right side once — a Scan, capped — and joins in memory: RIGHT keeps unmatched right rows (left columns omitted), FULL keeps unmatched rows from both sides, and CROSS is the Cartesian product (no ON, capped at 10,000 output rows). The strip shows the right step as a Scan and the editor warns — every right item is read and billed, so reach for these only when the right table is small or well-bounded.

With a live connection the editor goes one step further: it refuses up front when the right table is already larger than the 10,000-item cap (sized from the live count), so a doomed Scan never bills before it fails — the same safety stance the rest of the dialect takes on a full-table read.

Choosing a join shape by cost
INNER and LEFT resolve the right side per left row through its key — a BatchGetItem, or a Query per partition value — targeted and cheap. RIGHT, FULL and CROSS can't: they drain the whole right side as a Scan and join in memory. So reach for the outer/cross shapes only when the right table is small or well-bounded. When it isn't, either invert the join (make the small side the left) or add the GSI that lets the right side resolve by key — the same Scan-to-Query move from Targeted reads, one level up.

Multi-join chains

sql
SELECT o.orderId, c.emailFROM "stage.Orders" AS oJOIN "stage.Lines" AS l ON l.orderId = o.orderIdJOIN "stage.Customers" AS c ON c.customerId = l.customerId

Two or more JOINs in one statement run as a chain: the base table drains, then each table resolves per accumulated row through its key — BatchGetItem for a full key, one Query per partition value, or a Query on a GSI when the step binds an index key (the strip names it Query · <index>). The accumulated row carries every table's columns alias-qualified, so three or more tables can share an attribute name without colliding, and the final alias.attr projection runs once the chain is assembled. INNER semantics — a row that finds no match at a step drops.

ON conditions are attribute equality (ON c.customerId = l.customerId) or a Kanject key template (ON c.pk = 'Customer#{CustomerId}', the single-table-design form — placeholders resolve against the accumulated row, latest joined table first), binding the step table's base key or a GSI key. INNER and LEFT joins chain — a LEFT step keeps an accumulated row that finds no match (this table's columns then absent), so a chain can surface gaps; a RIGHT, FULL, or CROSS join inside a chain refuses. WHERE filters the base table only, and columns name alias.attr. Caps: 10,000 base rows · 1,000 queries and 10,000 items per step · 100,000 joined rows.

Set operations

sql
SELECT * FROM "stage.Active"  UNION      SELECT * FROM "stage.Archived"SELECT * FROM "stage.A"       INTERSECT  SELECT * FROM "stage.B"SELECT * FROM "stage.All"     EXCEPT     SELECT * FROM "stage.Excluded"-- combined sort + cut, and parenthesized mixing(SELECT * FROM "stage.A" UNION SELECT * FROM "stage.B")EXCEPT SELECT * FROM "stage.C"ORDER BY name LIMIT 10

UNION / UNION ALL / INTERSECT / EXCEPT have no native form, so the studio runs one read per operand and combines the result sets client-side by deep item equality: UNION de-duplicates, UNION ALL concatenates, INTERSECT keeps rows present in every operand, EXCEPT keeps the first operand's rows that appear in none of the rest. Each operand drains under the usual cap (10,000 items / 100 pages) — every operand's matched items are read and billed.

A trailing ORDER BY [keys] [LIMIT n] after the last operand applies to the combined set: a client-side sort, then the cut. Mixed operators compose through parentheses(A UNION B) EXCEPT C runs, because an operand may itself be a set expression. What still refuses, by design, is un-parenthesized mixing (A UNION B INTERSECT C): precedence isn't inferred, so parenthesize to say what you mean.

Caps for these features

Multi-request lowerings are capped; every breach refuses with a named fix rather than running away with your bill:

  • Semi-join: 10,000 distinct values · 100 pages · inline IN ≤ 100 values. Past the inline cap a partition-key set fans out into chunked key-lookup batches; a non-key set falls back to a disclosed full-read page filter.
  • Correlated subquery: 10,000 distinct values per run · outer capped at 500 rows (each one re-runs the subquery).
  • Joins: 10,000 left rows · 1,000 right-side queries (INNER/LEFT) · 10,000 right items. The right-side-queries cap refuses with bind the sort key for a BatchGetItem; a join whose ON reaches neither the base key nor a GSI key refuses with the GSI or materialized-view recommendation. RIGHT/FULL/CROSS drain the right side as a capped Scan (10,000 items); a CROSS product caps at 10,000 output rows, and any many-to-many match caps at 100,000 output rows.
  • Set operations: each operand 10,000 items · 100 pages.
  • Unknown tables, malformed subqueries and unbounded Scan subqueries are flagged in the editor before anything reaches the wire.
  • On Professional, these multi-request caps are tunable per install (Settings → Querying) — raising one can increase a query's read cost.
AWS background
Recap
  • IN (SELECT …) is a semi-join, NOT IN (SELECT …) an anti-join — both run the subquery first; only a partition-key IN gets the no-Scan key-lookup form.
  • A correlated subquery inverts to 1 + N — correlate on the partition key so each per-row run stays a Query.
  • INNER / LEFT resolve the right side per left row through a key (cheap); RIGHT / FULL / CROSS drain the right side as a capped Scan.
  • UNION / INTERSECT / EXCEPT run one read per operand and combine client-side; mix them only through parentheses.
  • Everything here folds after a read — every matched, filtered, or drained row is billed, and each cap refuses with a named fix.
Try it yourself 3
1 Compose a semi-join
From stage.Outbox, read only the rows whose partition key matches a bounced event in stage.Events — stored keys are shaped like Outbox#<id>.
Stored keys are template-shaped, so a bare SELECT Id subquery matches nothing. And give the subquery a LIMIT.
Show solution
The key template re-spells each event id into the stored key shape, and because the outer IN lands on the partition key, the outer read executes as key lookups — not a Scan.
sql
SELECT * FROM "stage.Outbox"WHERE pk IN (SELECT 'Outbox#{Id}' FROM "stage.Events"             WHERE EventType = 'Bounce' LIMIT 25)
2 Pick the cheap join shape
This drains all of stage.Customers as a capped Scan: SELECT o.orderId, c.email FROM "stage.Orders" AS o RIGHT JOIN "stage.Customers" AS c ON c.customerId = o.customerId. You only need customers that have failed orders — make the right side resolve by key.
Only RIGHT / FULL / CROSS drain the right side. INNER and LEFT resolve the right side per left row through its key.
Show solution
Invert the join: with Orders on the left, the left side drains once (its WHERE rides along) and each customer resolves through its key — a BatchGetItem, not a Scan.
sql
SELECT o.orderId, c.emailFROM "stage.Orders" AS oJOIN "stage.Customers" AS cON c.pk = 'Customer#{CustomerId}'WHERE o.status = 'FAILED'
3 Mix set operators safely
Combine stage.Active and stage.Archived, drop everything in stage.Excluded, and return the first 10 rows by name.
Un-parenthesized operator mixing refuses by design. A trailing ORDER BY … LIMIT applies to the combined set.
Show solution
Parentheses make the precedence explicit — an operand may itself be a set expression — and the trailing ORDER BY name LIMIT 10 sorts, then cuts, the combined set client-side.
sql
(SELECT * FROM "stage.Active" UNION SELECT * FROM "stage.Archived")EXCEPT SELECT * FROM "stage.Excluded"ORDER BY name LIMIT 10
Was this page helpful?