DynamoDB query and scan in TypeScript with dynotable-orm

How do you query DynamoDB in TypeScript?

Start the chain with an equality on a hash key, add conditions, and call exec(): Message.query('roomId').eq('general').where('sentAt').gt(since).exec(). dynotable-orm types each attribute and value from the schema, splits the conditions into the key condition and a filter, picks the index, and resolves to an array of typed items with lastKey for the next page.

A query reads one item collection: the items with one hash key value, on the table or on an index. A scan reads the whole table. Query vs scan explains the cost difference; use a query wherever an access pattern allows one.

A query

import {model} from 'dynotable-orm';
import {extendZod} from 'dynotable-orm/zod';
import {z} from 'zod';

extendZod(z);

const Message = model(
  'Message',
  z.object({
    roomId: z.string().hashKey(),
    sentAt: z.date().rangeKey(),
    author: z.string().index({name: 'byAuthor', rangeKey: 'sentAt'}),
    text: z.string(),
    deleted: z.boolean().default(false)
  })
);

const since = new Date('2026-11-01T00:00:00Z');

const recent = await Message.query('roomId')
  .eq('general')
  .where('sentAt')
  .gt(since)
  .filter('deleted')
  .eq(false)
  .sort('descending')
  .exec();

for (const m of recent) console.log(m.sentAt.toISOString(), m.author, m.text);
console.log(recent.count, recent.queriedCount, recent.lastKey);

There are three ways to start a query:

  • query('roomId').eq('general'), the chain.
  • query({roomId: 'general'}), the object form: each entry is an equality, or {field: {gt: v}}.
  • query().where('roomId').eq('general').

The chain is the condition chain: where(), filter(), the comparisons, and(), or(), not() and group(). Nothing is sent until exec(). Each call changes the query and returns it, so do not reuse one query object for two different requests.

The key condition and the filter

DynamoDB splits a query into two parts, and so does the library:

  • The key condition holds the equality on the hash key, and at most one condition on the range key with eq, lt, le, gt, ge, between or beginsWith. DynamoDB reads only the items that match it.
  • The filter expression holds every other condition. DynamoDB applies it after it reads the items, so a filtered-out item still costs read capacity.

In the query above, roomId and sentAt go into the KeyConditionExpression, and deleted into the FilterExpression. The key conditions must be joined by AND at the top level of the chain. Under an or(), they cannot be a key condition, and a query without a top-level equality on a hash key throws an ExpressionError (no index fits the query: it needs an equality on a hash key). Key condition expressions has DynamoDB's rules, and filtering strategies the ways to filter less.

Choosing the index

Without using(), the library picks the table or an index from the conditions. The rule, in short: an index whose hash key has an equality and whose range key is also in the conditions wins, else the table. Which index a query uses has the full rule.

import {model} from 'dynotable-orm';
import {extendZod} from 'dynotable-orm/zod';
import {z} from 'zod';

extendZod(z);

const Message = model(
  'Message',
  z.object({
    roomId: z.string().hashKey(),
    sentAt: z.date().rangeKey(),
    author: z.string().index({name: 'byAuthor', rangeKey: 'sentAt', project: ['text']}),
    text: z.string(),
    reactions: z.number().default(0)
  })
);

const byAda = await Message.query('author').eq('ada').using('byAuthor').exec();
console.log(byAda[0]?.text); // the index projects `text`

// @ts-expect-error: byAuthor does not project `reactions`
console.log(byAda[0]?.reactions);

// @ts-expect-error: the model has no index 'byRoom'
Message.query('roomId').eq('general').using('byRoom');

using() takes only a declared index name, and narrows each item to what the index projects. A using() of an index whose hash key has no equality in the conditions throws an ExpressionError before sending.

Sort order

A query returns the items in range key order, ascending. sort('descending') reverses it (ScanIndexForward: false): the newest messages first, above. A scan has no order, and sort() on a scan does not compile. Query in descending order covers the common uses.

Pagination

DynamoDB returns at most 1 MB per request. When there is more, the result's lastKey is set: pass it to startAt() for the next page.

import {model} from 'dynotable-orm';
import {extendZod} from 'dynotable-orm/zod';
import {z} from 'zod';

extendZod(z);

const Message = model(
  'Message',
  z.object({roomId: z.string().hashKey(), sentAt: z.date().rangeKey(), text: z.string()})
);

let startAt: Record<string, unknown> | undefined;
let pages = 0;
do {
  const page = await Message.query('roomId').eq('general').limit(50).startAt(startAt).exec();
  for (const m of page) console.log(m.text);
  startAt = page.lastKey;
  pages++;
} while (startAt);
console.log(pages);

// Or every page in one call: 100 ms apart, at most 20 requests.
const all = await Message.query('roomId').eq('general').all(100, 20).exec();
console.log(all.count, all.timesQueried);
  • limit(n) is DynamoDB's Limit. It caps the items DynamoDB reads per request, before the filter. So a page with a filter can hold fewer than n items, even none, and still have a lastKey. Loop on lastKey, not on the page size.
  • lastKey is a plain object of the key attributes, in their stored form: a Date key is its number or ISO string. It survives JSON.stringify, so it can go into an API cursor. startAt() takes it back, and also a key in attribute-value form.
  • startAt(undefined) and startAt({}) start at the beginning, so the loop above needs no special first page.
  • all(delay, maxPages) reads page after page into one result. It waits delay ms only before each next page, and stops after maxPages requests (0, the default, means no limit). With maxPages, the result's lastKey says where it stopped.

DynamoDB pagination covers the 1 MB rule and cursor design.

The result

exec() resolves to an array of items, typed from the schema, with:

fieldqueryscanwhat it is
countyesyesthe items returned, after the filter
lastKeyyesyesthe key to pass to startAt(), or undefined on the last page
queriedCountyesDynamoDB's ScannedCount: the items read, before the filter
timesQueriedyesthe number of requests sent
scannedCountyesthe items read, before the filter
timesScannedyesthe number of requests sent

The gap between count and queriedCount is what the filter threw away after DynamoDB read it. dynamoose's queriedCount is always undefined; here it holds DynamoDB's number.

Other settings

  • attributes(['text', 'sentAt']) reads only those attributes (a projection expression), and narrows the item type to them. A string[] built at run time types each item as a Partial of the item.
  • count() makes exec() resolve to the counts only, {count, queriedCount} (or scannedCount), with Select: 'COUNT'. DynamoDB still reads the items, so the cost is the same.
  • consistent() asks for a strongly consistent read. DynamoDB supports it on the table and on local indexes, not on global ones. Consistency explains the cost.

consistent() and count() turn their setting on; calling them twice does not turn it off.

Scan

scan() takes the same chain without sort(). Every condition is a filter, and DynamoDB reads every item in the table:

import {model} from 'dynotable-orm';
import {extendZod} from 'dynotable-orm/zod';
import {z} from 'zod';

extendZod(z);

const Message = model(
  'Message',
  z.object({roomId: z.string().hashKey(), sentAt: z.date().rangeKey(), deleted: z.boolean()})
);

const deleted = await Message.scan('deleted').eq(true).parallel(4).exec();
console.log(deleted.count, deleted.scannedCount, deleted.timesScanned);

parallel(segments) scans that many segments at once and merges the results. When several segments stop before their end, lastKey is an array with one key per segment. startAt() refuses an array, because one request takes one key: to resume a parallel scan, scan each segment yourself with the SDK. parallel() on a query throws an ExpressionError.

What throws before sending

  • A query with no top-level equality on a hash key, or with its key conditions under an or().
  • using() of an undeclared index (it also does not compile), or of an index without an equality on its hash key.
  • parallel() on a query, and startAt() with an array.
  • A condition with no comparison (where('roomId') alone), an undefined value, or an empty in().

exec({client, abortSignal}) sets the client and the abort signal of every request of that call. Without them, queries and scans use the model's client option or the client from setClient(). They have no {return: 'request'}. The full list of differences from dynamoose is under Queries and scans in the migration guide.

Next steps

Updated