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,betweenorbeginsWith. 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'sLimit. It caps the items DynamoDB reads per request, before the filter. So a page with a filter can hold fewer thannitems, even none, and still have alastKey. Loop onlastKey, not on the page size.lastKeyis a plain object of the key attributes, in their stored form: a Date key is its number or ISO string. It survivesJSON.stringify, so it can go into an API cursor.startAt()takes it back, and also a key in attribute-value form.startAt(undefined)andstartAt({})start at the beginning, so the loop above needs no special first page.all(delay, maxPages)reads page after page into one result. It waitsdelayms only before each next page, and stops aftermaxPagesrequests (0, the default, means no limit). WithmaxPages, the result'slastKeysays 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:
| field | query | scan | what it is |
|---|---|---|---|
count | yes | yes | the items returned, after the filter |
lastKey | yes | yes | the key to pass to startAt(), or undefined on the last page |
queriedCount | yes | DynamoDB's ScannedCount: the items read, before the filter | |
timesQueried | yes | the number of requests sent | |
scannedCount | yes | the items read, before the filter | |
timesScanned | yes | the 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. Astring[]built at run time types each item as aPartialof the item.count()makesexec()resolve to the counts only,{count, queriedCount}(orscannedCount), withSelect: '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, andstartAt()with an array.- A condition with no comparison (
where('roomId')alone), anundefinedvalue, or an emptyin().
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
- Keys and indexes: declaring the indexes a query can use.
- The
QueryandQueryResponsereference.