Query DynamoDB With AWS SDK v3: Keys, Indexes and Pagination

Wooden library card catalog with rows of small labelled drawers

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-dynamodb and @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

  1. Create one document client per processBuild it at module level from a DynamoDBClient, so connections and credentials are reused across requests.
  2. Write the key conditionThe partition key must use =. The sort key can use =, <, <=, >, >=, BETWEEN or begins_with (not on Number sort keys).
  3. Use placeholders for every valueValues go in ExpressionAttributeValues as :name. Attribute names that are reserved words, such as status, total or size, go in ExpressionAttributeNames as #name.
  4. Pick the sourceLeave IndexName off to query the table, or set it to query a local or global secondary index.
  5. Set order and consistencyScanIndexForward: false returns the highest sort keys first. ConsistentRead: true is allowed on the table and local indexes only.
  6. PaginateUse paginateQuery to read everything, or pass LastEvaluatedKey back as ExclusiveStartKey for one page at a time.

Example: five query patterns in one module

orders-queries.ts

// 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: false mean “newest first” and BETWEEN work as a date range. The ￿ suffix on the upper bound includes every order on the last day.
  • The cursor is opaque but not secret. Base64url-encoded LastEvaluatedKey reveals 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

// 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));
Terminal

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 ignores LastEvaluatedKey works in development and silently truncates in production once a partition passes 1 MB.
  • paginateQuery handles the loop. It’s exported by @aws-sdk/lib-dynamodb for the document client (and by @aws-sdk/client-dynamodb for the base client). Its pageSize option sets Limit on each request.
  • An empty page doesn’t mean the end. A query can return zero items and still a LastEvaluatedKey when a filter removed everything it read. Only a missing LastEvaluatedKey means 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: 10 with 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 ProjectionExpression doesn’t reduce it either.
  • ScannedCount in the response is the number read; Count is 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: true on a GSI fails with ValidationException. 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:

query-orders-policy.json

{
  "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. Check IndexName.
  • ValidationException mentioning a reserved keyword. Replace the attribute name with a #placeholder in ExpressionAttributeNames.
  • ValidationException on ConsistentRead. You set it on a GSI query. Remove it.
  • ResourceNotFoundException. Wrong table or index name, or the wrong Region. Log the resolved Region from await 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.
  • AccessDeniedException only 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.
  • ProvisionedThroughputExceededException or ThrottlingException. 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_INTEGER lose precision; enable wrapNumbers in unmarshallOptions.

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_with doesn’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