Photo by Jan Antonin Kolar on Unsplash
To query DynamoDB with AWS SDK v3, wrap a DynamoDBClient with DynamoDBDocumentClient.from() from @aws-sdk/lib-dynamodb and send a QueryCommand with a KeyConditionExpression that tests the partition key for equality. Add a sort key condition or IndexName to narrow it, and loop with paginateQuery, because each call returns at most 1 MB.
This guide is for Node.js and TypeScript developers who already have a DynamoDB table and need to query DynamoDB with AWS SDK v3 correctly: the right key conditions, secondary indexes, pagination that doesn’t silently drop items, and filters that don’t surprise you on the bill. You’ll finish with a typed module of five query patterns you can copy into a service.
If you’re still moving code off AWS.DynamoDB.DocumentClient, the client swap itself is covered in the guide to migrate a Node.js app from AWS SDK v2 to v3. This page starts where that one stops: what to put inside the QueryCommand. For changing items rather than reading them, see how to update DynamoDB items with conditions and atomic counters in AWS SDK v3.
Query vs Scan vs GetItem: which one do you need?
| Operation | You know | Reads |
|---|---|---|
GetCommand |
The full primary key | One item |
QueryCommand |
The partition key value, optionally a sort key range | Only items in that partition, in sort key order |
ScanCommand |
Nothing about the key | Every item in the table or index |
If an access pattern needs a Scan in production, that’s usually a sign you need a global secondary index (GSI) with a different partition key, which you can then query. The rest of this guide uses an orders table with partition key pk (CUSTOMER#c-1001), sort key sk (ORDER#2026-09-14T10:22:05Z#o-77) and a GSI named status-createdAt-index.
Prerequisites
- Node.js 20 or later, TypeScript and
tsx. @aws-sdk/client-dynamodband@aws-sdk/lib-dynamodb. The document client converts between native JavaScript values and DynamoDB’s typed{ S: "..." }format for you.- Credentials the SDK can find. If a query works locally but not in CI, the guide to AWS SDK v3 credential providers such as fromIni and fromSSO explains which source wins.
How to query DynamoDB with AWS SDK v3, step by step
- Create one document client per processBuild it at module level from a
DynamoDBClient, so connections and credentials are reused across requests. - Write the key conditionThe partition key must use
=. The sort key can use=,<,<=,>,>=,BETWEENorbegins_with(not on Number sort keys). - Use placeholders for every valueValues go in
ExpressionAttributeValuesas:name. Attribute names that are reserved words, such asstatus,totalorsize, go inExpressionAttributeNamesas#name. - Pick the sourceLeave
IndexNameoff to query the table, or set it to query a local or global secondary index. - Set order and consistency
ScanIndexForward: falsereturns the highest sort keys first.ConsistentRead: trueis allowed on the table and local indexes only. - PaginateUse
paginateQueryto read everything, or passLastEvaluatedKeyback asExclusiveStartKeyfor one page at a time.
Example: five query patterns in one module
// orders-queries.ts
// Query patterns for an "orders" table with AWS SDK v3 and the DynamoDB document client.
// Table: partition key pk (e.g. "CUSTOMER#c-1001"), sort key sk (e.g. "ORDER#2026-09-14T10:22:05Z#o-77").
// GSI "status-createdAt-index": partition key status, sort key createdAt.
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, QueryCommand, paginateQuery, type QueryCommandInput } from "@aws-sdk/lib-dynamodb";
const TABLE = process.env.ORDERS_TABLE ?? "orders";
// One client per process. removeUndefinedValues matters for writes; reads don't need it.
export const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}), {
marshallOptions: { removeUndefinedValues: true },
});
export type Order = {
pk: string;
sk: string;
orderId: string;
status: "PENDING" | "SHIPPED" | "CANCELLED";
createdAt: string;
total: number;
};
/** 1. Newest orders for one customer: partition key equality + begins_with on the sort key. */
export async function latestOrders(customerId: string, max = 20): Promise<Order[]> {
const out = await ddb.send(
new QueryCommand({
TableName: TABLE,
KeyConditionExpression: "pk = :pk AND begins_with(sk, :prefix)",
ExpressionAttributeValues: { ":pk": `CUSTOMER#${customerId}`, ":prefix": "ORDER#" },
ScanIndexForward: false, // newest first: sort keys start with an ISO date
Limit: max,
}),
);
return (out.Items ?? []) as Order[];
}
/** 2. Orders in a date range, strongly consistent (allowed on the table, not on a GSI). */
export async function ordersBetween(customerId: string, from: string, to: string): Promise<Order[]> {
const items: Order[] = [];
const pages = paginateQuery(
{ client: ddb, pageSize: 100 },
{
TableName: TABLE,
KeyConditionExpression: "pk = :pk AND sk BETWEEN :from AND :to",
ExpressionAttributeValues: {
":pk": `CUSTOMER#${customerId}`,
":from": `ORDER#${from}`,
":to": `ORDER#${to}`, // include every order on the "to" day
},
ConsistentRead: true,
},
);
for await (const page of pages) items.push(...((page.Items ?? []) as Order[]));
return items;
}
/** 3. All orders with one status since a date, from the GSI, with a filter on a non-key attribute. */
export async function bigOrdersByStatus(status: Order["status"], since: string, minTotal: number): Promise<Order[]> {
const items: Order[] = [];
let scanned = 0;
const pages = paginateQuery(
{ client: ddb },
{
TableName: TABLE,
IndexName: "status-createdAt-index",
KeyConditionExpression: "#status = :status AND createdAt >= :since",
FilterExpression: "#total >= :min", // applied after the read: you pay for scanned items
ExpressionAttributeNames: { "#status": "status", "#total": "total" }, // both are reserved words
ExpressionAttributeValues: { ":status": status, ":since": since, ":min": minTotal },
},
);
for await (const page of pages) {
scanned += page.ScannedCount ?? 0;
items.push(...((page.Items ?? []) as Order[]));
}
console.log(`read ${scanned} items from the index, returned ${items.length}`);
return items;
}
/** 4. One page for an API, with an opaque cursor built from LastEvaluatedKey. */
export async function orderPage(
customerId: string,
pageSize: number,
cursor?: string,
): Promise<{ items: Order[]; nextCursor?: string }> {
const input: QueryCommandInput = {
TableName: TABLE,
KeyConditionExpression: "pk = :pk AND begins_with(sk, :prefix)",
ExpressionAttributeValues: { ":pk": `CUSTOMER#${customerId}`, ":prefix": "ORDER#" },
ScanIndexForward: false,
Limit: pageSize,
};
if (cursor) input.ExclusiveStartKey = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
const out = await ddb.send(new QueryCommand(input));
const nextCursor = out.LastEvaluatedKey
? Buffer.from(JSON.stringify(out.LastEvaluatedKey)).toString("base64url")
: undefined;
return { items: (out.Items ?? []) as Order[], nextCursor };
}
/** 5. Count without returning items. Still reads (and bills) every matching item. */
export async function countOrders(customerId: string): Promise<number> {
let count = 0;
const pages = paginateQuery(
{ client: ddb },
{
TableName: TABLE,
KeyConditionExpression: "pk = :pk",
ExpressionAttributeValues: { ":pk": `CUSTOMER#${customerId}` },
Select: "COUNT",
},
);
for await (const page of pages) count += page.Count ?? 0;
return count;
}
A few things in this module are easy to get wrong:
- Sort keys that start with an ISO timestamp sort by time. That’s what makes
ScanIndexForward: falsemean “newest first” andBETWEENwork as a date range. Thesuffix on the upper bound includes every order on the last day. - The cursor is opaque but not secret. Base64url-encoded
LastEvaluatedKeyreveals the key values. If those are sensitive, sign or encrypt the cursor, and always check on the server that the decoded key belongs to the caller’s partition. - Types are asserted, not checked.
as Order[]trusts the table. Validate items at the boundary if other writers can put different shapes into the same table.
Calling it from a script:
// run-queries.ts
import { latestOrders, orderPage, countOrders } from "./orders-queries.js";
const customerId = process.argv[2] ?? "c-1001";
const recent = await latestOrders(customerId, 5);
console.table(recent.map(({ orderId, status, createdAt, total }) => ({ orderId, status, createdAt, total })));
const first = await orderPage(customerId, 2);
console.log("page 1:", first.items.map((o) => o.orderId), "next cursor:", first.nextCursor ?? "none");
console.log("total orders:", await countOrders(customerId));
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
npm install --save-dev tsx typescript
AWS_PROFILE=dev AWS_REGION=eu-west-1 ORDERS_TABLE=orders npx tsx run-queries.ts c-1001
# page 1: [ 'o-91', 'o-88' ] next cursor: eyJwayI6IkNVU1RPTUVSI2MtMTAwMSIsInNrIjoi...
# total orders: 37
How does DynamoDB pagination work in SDK v3?
A single Query reads up to 1 MB of data, or up to Limit items if you set it, and then stops. If there’s more, the response includes LastEvaluatedKey. Three consequences:
- One
send()is not “all results”. Code that ignoresLastEvaluatedKeyworks in development and silently truncates in production once a partition passes 1 MB. paginateQueryhandles the loop. It’s exported by@aws-sdk/lib-dynamodbfor the document client (and by@aws-sdk/client-dynamodbfor the base client). ItspageSizeoption setsLimiton each request.- An empty page doesn’t mean the end. A query can return zero items and still a
LastEvaluatedKeywhen a filter removed everything it read. Only a missingLastEvaluatedKeymeans you’re done.
The lib-dynamodb README in the AWS SDK for JavaScript v3 repository documents the marshalling options, such as removeUndefinedValues and wrapNumbers for numbers beyond JavaScript’s safe integer range.
Limit vs FilterExpression: why a query reads more than it returns
This is the most common surprise when you query DynamoDB with AWS SDK v3. Limit is the number of items to evaluate, not the number to return. FilterExpression runs after the read, so:
Limit: 10with a filter can return anywhere from 0 to 10 items.- You pay read capacity for every item read, filtered or not. Read capacity is based on item size, so
ProjectionExpressiondoesn’t reduce it either. ScannedCountin the response is the number read;Countis the number returned. A large gap means the filter is doing work a key or index should do.Select: "COUNT"returns only the count but consumes the same read capacity as fetching the items.
A filter can’t reference key attributes; those belong in the key condition. When bigOrdersByStatus logs “read 4,200 items, returned 12”, add the total to the index’s sort key or create a sparse index for large orders instead of filtering.
Querying a global secondary index
Set IndexName and write the key condition against the index’s keys. Differences from the table:
- Eventually consistent only.
ConsistentRead: trueon a GSI fails withValidationException. A write can take a short time to appear in the index. - Only projected attributes. A GSI query can’t fetch attributes that weren’t projected into the index. A local secondary index can, at extra read cost.
- Separate throughput. On provisioned tables, a GSI has its own capacity. If you right-size capacity, the example to find overprovisioned DynamoDB read and write capacity shows how to compare it with actual use.
Which IAM permissions does Query need?
dynamodb:Query on the table ARN, and on the index ARN if you query an index. Index ARNs have the form table/NAME/index/INDEX:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "QueryOrdersAndIndexes",
"Effect": "Allow",
"Action": "dynamodb:Query",
"Resource": [
"arn:aws:dynamodb:eu-west-1:123456789012:table/orders",
"arn:aws:dynamodb:eu-west-1:123456789012:table/orders/index/*"
]
}
]
}
For multi-tenant tables, the dynamodb:LeadingKeys condition key can restrict a caller to partition keys that match their identity. To derive the action list from your own code, see how to find the IAM actions your AWS SDK for JavaScript code needs, or paste the module into the IAM policy generator for TypeScript code.
Troubleshooting and common mistakes
ValidationException: Query condition missed key schema element. The key condition doesn’t name the partition key of the table or index you’re querying. CheckIndexName.ValidationExceptionmentioning a reserved keyword. Replace the attribute name with a#placeholderinExpressionAttributeNames.ValidationExceptiononConsistentRead. You set it on a GSI query. Remove it.ResourceNotFoundException. Wrong table or index name, or the wrong Region. Log the resolved Region fromawait client.config.region(). If table names come from configuration, the guide to read SSM Parameter Store values with AWS SDK v3 shows a cached loader that fails loudly on a missing name.AccessDeniedExceptiononly on index queries. The policy covers the table ARN but not/index/*. The steps to troubleshoot AWS IAM access denied errors help when it’s something else.ProvisionedThroughputExceededExceptionorThrottlingException. The SDK retries these automatically; if they persist, look at hot partitions and capacity. To tune the retries, see how to configure retry and timeout settings in AWS SDK for JavaScript v3.- Numbers come back as the wrong value. Values beyond
Number.MAX_SAFE_INTEGERlose precision; enablewrapNumbersinunmarshallOptions.
Limits to keep in mind
- Query only works within one partition key value. Anything across partitions needs another index or a Scan.
- 1 MB of data read per request, before filtering.
begins_withdoesn’t work on Number sort keys.- GSI reads are eventually consistent.
If you’re porting query code from Python, the guide to port a Python boto3 script to Node.js with AWS SDK v3 covers the translation, and the boto3 to AWS SDK v3 converter drafts the first version. To look at a table’s shape before writing queries, ChatWithCloud can answer “What are the key schema and indexes of the orders table?” from a read-only profile; it runs the AWS calls on your machine and sends the results to the AI model to write the answer. Before you run bulk writes or a data migration against a table, enable DynamoDB point-in-time recovery so a bad script can be rolled back.
Frequently asked questions
How do I query DynamoDB by partition key in Node.js?
Send new QueryCommand({ TableName, KeyConditionExpression: "pk = :pk", ExpressionAttributeValues: { ":pk": "CUSTOMER#c-1001" } }) through a DynamoDBDocumentClient and read Items.
How do I get all results from a DynamoDB query in SDK v3?
Loop over paginateQuery({ client }, input) with for await, or repeat the query with ExclusiveStartKey set to the previous LastEvaluatedKey until it’s absent.
Why does my DynamoDB query return fewer items than Limit?
Limit caps the items read, and a FilterExpression removes some of them afterwards. The 1 MB per-request cap can also stop the page early.
Can I sort DynamoDB query results by a non-key attribute?
No. Results are always in sort key order. Create an index whose sort key is the attribute you need to sort by.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud