PartiQL: targeted reads

View .md

Part of the dialect series: the difference between a read that touches three items and one that reads — and bills — the whole table is often a single predicate. Start with the ordinary Query/Scan path, then use the same editor for native vector similarity and explicitly parallelized scans — each with its own cost and guardrails disclosed before the wire.

You'll learn
  • Tell a key condition (bounds what DynamoDB reads) from a filter (applied after — still billed)
  • Keep a read a cheap Query, and recognise the predicates that degrade it to a Scan
  • Read a GSI as a target — including multi-key GSIs with several partition and sort attributes
  • Run a top-k vector similarity search as one native SearchVectors request
  • Sort and page a result set, and know which rows you still pay for
  • Use PARALLEL n when an intentional Scan should finish sooner — without mistaking faster for cheaper

The Query/Scan divide

sql
SELECT * FROM "stage.Outbox" WHERE pk = 'Outbox#123';       -- QuerySELECT * FROM "stage.Outbox" WHERE pk IN ('O#1', 'O#2');     -- key lookupsSELECT * FROM "stage.Outbox"WHERE pk = 'Outbox#123' AND begins_with(sk, 'EVT#');         -- narrowed QuerySELECT * FROM "stage.Outbox" WHERE Status = 'pending';       -- Scan (warned)

A partition-key equality or IN list in WHERE avoids the full read — DynamoDB's equality-or-IN rule. Equality executes as a Query; an IN list executes as one key lookup per value (an IN-driven read claims no sort-key narrowing — every other predicate filters after those lookups). Anything else degrades to a Scan that reads — and bills — every item. When the attribute you need to filter by isn't a key, the fix is a GSI whose partition key is that attribute: read it with the FROM "table"."index" form and the filter becomes a key condition again (multi-attribute GSIs are below).

The editor points at the exact predicate (or OR) responsible for a Scan, and with a live connection the warning is sized with the table's item count ("~432,926 items at last describe"). Tables outside your parsed schema still classify: their live key schema stands in, so pk IN (…) on an unmapped table reads as key lookups, not "can't classify".

Try it · PartiQLSample
Executes asScan · stage.Outbox
Scan FilterExpression: Status = 'pending' · ~432,926 items at last describe
No key condition — every item is read and billed, then filtered. The matched rows are a sliver of what you pay for.
Switch to the keyed variant to see the same question asked for the price of one partition.
#pkskStatus
1Outbox#123EVT#0007pending
2Outbox#128EVT#0003pending
3Outbox#131EVT#0011pending
Fetched 3 items in 384ms · <0.01% efficient · 541 RCU · 24 partitions

Three rows back, the whole table read to find them — that 0.0007% is the Scan tax. Now flip to the Keyed — Query variant: the same question with a partition-key equality in front reads 12 items instead of 432,926, and the strip turns green before anything runs.

And the tax compounds as the table grows. Drag the slider — the Scan's bill tracks the table, the keyed Query's doesn't:

The Scan tax, by table size Illustrative
Keyed Query bills 1,200
Scan bills 100,000
Same question, same rows back — the Scan reads 83× more.
The keyed Query reads one partition (~1,200 items here) no matter how big the table grows — that flat line is the whole argument for modeling access patterns first.

WHERE: the predicate reference

For ordinary table and GSI Query/Scan reads, all of these predicates are native. What varies is where they execute — in the key condition (bounding what DynamoDB reads) or in the filter (applied after the read; matched items are still read and billed):

  • Comparisons= <> != < <= > >=. Partition-key equality forms the key condition; sort-key comparisons on a queried target narrow it; anything else filters.
  • BETWEEN low AND high — on the queried sort key it narrows the key condition itself; elsewhere it filters.
  • IN ('a', 'b') — literal list. On the partition key it stays a key condition (one key lookup per value); never on a sort key; elsewhere it filters.
  • AND — composes key conditions and filters freely. OR / NOT — can never form a key condition; their presence around key predicates degrades the read to a Scan, and the editor says so.
  • IS MISSING / IS NOT MISSING — attribute absence. Absent is not the same as NULL: DynamoDB stores explicit nulls, and the two test differently.
  • Functionsbegins_with(path, prefix), contains(path, value), attribute_type(path, type), size(path). On a queried sort key, begins_with narrows the key condition; the rest always filter.

Multi-key GSIs

sql
-- a multi-key GSI: two partition + two sort attributesSELECT * FROM "stage.Orders"."byRegionStatus"WHERE region = 'eu' AND tenant = 'acme'         -- equality on every partition attr  AND status = 'open' AND createdAt >= '2026'   -- sort attrs, left-to-right-- a gap, a missing partition equality, or a 2nd range → Scan

A GSI isn't limited to one partition key and one sort key. It can carry up to four partition and four sort attributes, kept separate and typed — not concatenated into one region#tenant#status string you have to parse back out. The studio classifies a read against one as a Query only when it honours the multi-key rule:

  • Equality on every partition attribute — all of them, no exceptions; a missing one degrades to a Scan.
  • Sort attributes bound left-to-right, no gaps — you may stop early, but you can't skip one and bind a later one.
  • At most one trailing inequality< <= > >= BETWEEN, or begins_with (which must come last). A second range degrades to a Scan.

A gap, a missing partition equality, or a second range turns the read into a Scan, and the strip names the attribute that broke the rule — so you see why it fell off the Query path, not just that it did.

Try it · Multi-key GSISample
Executes asQuery · stage.Orders.byRegionStatus
Query KeyConditionExpression: region = 'eu' AND tenant = 'acme' AND status = 'open' AND createdAt >= '2026'
Equality on both partition attributes, then both sort attributes left-to-right with one trailing range — a single targeted Query on the index.
Native: a multi-key GSI binds up to 4 partition + 4 sort attributes, kept separate and typed.
#orderIdregionstatuscreatedAt
1Order#5821euopen2026-02-11
2Order#5840euopen2026-03-02
Fetched 2 items in 9ms · 100% efficient · 1 RCU · 1 partition

This is the green counterpart to the amber Scan above: same table, but every key attribute is pinned the way the rule wants, so the whole read is one targeted Query on the index. Drop the tenant equality or reorder the sort attributes in DynoStudio and the strip flips amber, naming what broke. (Following along on the sample tables? byRegionStatus is the one-statement optional step from the setup page — run it and this exact query returns these exact two orders.)

sql
SELECT ProductId, Title,       VECTOR_DISTANCE(Embedding, {queryVector}) AS scoreFROM "Products"."ProductEmbeddingIndex"WHERE Category = {category} AND Brand = 'Acme'ORDER BY VECTOR_DISTANCE(Embedding, {queryVector}) ASCLIMIT 10

VECTOR_DISTANCE targets a live DynamoDB vector index and lowers to one native, eventually consistent SearchVectors request. The returned score comes from the service; DynoStudio never recomputes it client-side. When selected, the score needs an explicit, non-colliding AS alias. COSINE and EUCLIDEAN use ASC for nearest-first, while DOT_PRODUCT uses DESC; omitting the direction uses the index's nearest-first order.

  • Vector binding{queryVector} is a numeric PartiQL list such as [0.12, -0.34, 0.56]. Its length must exactly match the index dimensions, and every value must be a finite 32-bit number. History stores only its digest and dimension count, then asks you to bind the vector again on replay.
  • Top-k boundLIMIT is required and must be 1100. One request returns one page.
  • Search schema — a HASH attribute declared by PARTITION BY must be pinned with =. Optional conditions may use only top-level INLINE_FILTER attributes declared by FILTER BY, also joined with = and AND.
  • Projection — select *, projected top-level attributes, and optionally one aliased score. Fetching a non-projected base-table attribute needs a separate join-back shape.
  • Readiness and cost — the table must be on-demand and the index must be ACTIVE with backfill finished. Vector search is byte-metered rather than RCU-metered; the receipt reports VectorSearchRequestBytes.

The editor uses the current DescribeTable descriptor to complete vector-index names, the vector and search-schema attributes, dimensions, distance metric, projection, and readiness. To create the target itself, use CREATE VECTOR INDEX.

Sorting and paging

sql
-- native: provably the queried sort key, server-side orderSELECT * FROM "stage.Users" WHERE pk = 'User#9' ORDER BY sk DESC;-- lowered: per-page client sort, disclosedSELECT * FROM "stage.Users" ORDER BY Country ASC, Age DESC NULLS LASTLIMIT 50 OFFSET 10;
  • ORDER BY (native case) — a single key that provably is the queried target's sort key stays in the native statement, so DynamoDB returns rows in index order (ASC/DESC).
  • ORDER BY (lowered case) — multi-key lists, non-key attributes, document paths (ORDER BY address.city), or unprovable targets sort each fetched page client-side, disclosed in the strip. Type order: booleans before numbers before text. Absent values sort as largest unless NULLS FIRST / NULLS LAST says otherwise.
  • LIMIT n — core PartiQL that DynamoDB's dialect rejects; the studio sends it as the request's page limit.
  • OFFSET n — skipped rows are fetched on top of the first page and dropped client-side. Skipped rows are still read and billed. First page only.
  • DISTINCT — de-duplicates each fetched page client-side by deep value equality (numbers by value, sets order-insensitive). Like a client-side ORDER BY, it acts per page, so duplicates across page boundaries can reappear.

PARALLEL n — faster scans, same bill

sql
SELECT * FROM "stage.Orders"WHERE status = 'FAILED' PARALLEL 4;SELECT region, SUM(order.total)FROM "stage.Orders" GROUP BY region PARALLEL 4;PROFILE TABLE "stage.Orders" PARALLEL 4;

A trailing PARALLEL n runs an intentional Scan as n concurrent DynamoDB segments and merges them client-side. It can shorten wall-clock time for a profile, full-table aggregate, or sweep, but it reads the same items and bills the same total RCUs as a serial Scan — while consuming that capacity at up to n× concurrently. The Executes-as strip discloses the fan-out and throttle risk before the run.

  • Scans only — on a key-condition Query it is a no-op. Base tables and GSIs are supported; a parallel GSI scan is eventually consistent.
  • Pushable filters only — comparison, BETWEEN, begins_with, IN, IS [NOT] MISSING, and boolean composition can stay parallel. Unsupported predicates, a client-side ORDER BY, subqueries, or set operations run serially with the reason shown.
  • Bounded outputLIMIT stops the fan-out when enough rows arrive, so which rows win is nondeterministic just as it is for a serial Scan. Without LIMIT, a plain parallel SELECT returns one fast page; aggregates and PROFILE TABLE drain and fold the full scan.
  • Guarded concurrency — Block Scans still blocks it. The degree is capped in Settings → Querying (default 4); above the configured cap it refuses. Parallel scan acceleration is a Professional capability.
AWS background
Recap
  • For ordinary table and GSI reads, a partition-key equality or IN is the only thing that turns the access path into a cheap Query; everything else Scans.
  • On a queried sort key, begins_with / BETWEEN / comparisons narrow the read; every other predicate filters after it (matched rows still billed).
  • A GSI — including a multi-key one (up to 4 partition + 4 sort attributes) — reads with FROM "table"."index", and is Query-or-Scan by the same rule.
  • A vector-index SELECT lowers to one byte-metered SearchVectors request; LIMIT 1..100, descriptor-valid filters, and a ready index are required.
  • OFFSET-skipped rows are still read and billed; client-side ORDER BY and DISTINCT act per fetched page.
  • PARALLEL n makes a Scan faster, not cheaper, and can consume capacity up to n× concurrently.
Check yourself4
Which WHERE clause keeps a read of stage.Outbox (keys pk, sk) a cheap Query?
A read runs LIMIT 50 OFFSET 100. How many rows do you pay for?
A multi-key GSI has partition attributes (region, tenant) and sort attributes (status, createdAt). Which read is a Query?
What does PARALLEL 4 change about an intentional full-table Scan?
Try it yourself 3
1 Turn a Scan into a Query
This reads the whole table: SELECT * FROM "stage.Outbox" WHERE Status = 'pending'. Narrow it to a single partition.
A Scan becomes a Query the moment a partition-key equality joins the WHERE. Status can stay — as a filter.
Show solution
Add a pk equality: pk = … forms the key condition (a Query); Status = … then filters within that partition.
sql
SELECT * FROM "stage.Outbox"WHERE pk = 'Outbox#123' AND Status = 'pending'
2 Narrow with a sort-key range
Within one partition, return only the events whose sort key falls in a range.
On a queried sort key, BETWEEN narrows the key condition itself — it doesn't just filter.
Show solution
BETWEEN on the sort key narrows the read server-side, so DynamoDB only touches the matching slice — not the whole partition.
sql
SELECT * FROM "stage.Outbox"WHERE pk = 'Outbox#123' AND sk BETWEEN 'EVT#0100' AND 'EVT#0200'
3 Query a multi-key GSI
Read "stage.Orders"."byRegionStatus" for open orders in region eu, tenant acme, created since 2026.
Equality on every partition attribute (region, tenant), then the sort attributes left-to-right (status, createdAt).
Show solution
All partition attributes use equality and the sort attributes bind left-to-right with one trailing range — so it stays a Query on the index.
sql
SELECT * FROM "stage.Orders"."byRegionStatus"WHERE region = 'eu' AND tenant = 'acme'  AND status = 'open' AND createdAt >= '2026'
Was this page helpful?