DynamoDB Query in Go (AWS SDK v2)

Query reads one partition, optionally narrowed by the sort key (Query vs Scan covers when that is the right call, and key condition expressions lists every legal operator). What AWS SDK for Go v2 adds is QueryPaginator, which turns the LastEvaluatedKey loop into a for loop and, in doing so, quietly removes your chance to stop.

Code

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)

func main() {
	ctx := context.TODO()
	cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion("us-east-1"))
	if err != nil {
		log.Fatalf("load config: %v", err)
	}
	client := dynamodb.NewFromConfig(cfg)

	paginator := dynamodb.NewQueryPaginator(client, &dynamodb.QueryInput{
		TableName: aws.String("Music"),
		KeyConditionExpression: aws.String(
			"#hashKey = :hashKeyValue AND begins_with(#rangeKey, :rangeKeyValue)"),
		ExpressionAttributeNames: map[string]string{
			"#hashKey":  "Artist",
			"#rangeKey": "SongTitle",
		},
		ExpressionAttributeValues: map[string]types.AttributeValue{
			":hashKeyValue":  &types.AttributeValueMemberS{Value: "Arturo Sandoval"},
			":rangeKeyValue": &types.AttributeValueMemberS{Value: "C"},
		},
	})

	var items []map[string]types.AttributeValue
	for paginator.HasMorePages() {
		page, err := paginator.NextPage(ctx)
		if err != nil {
			log.Fatalf("query: %v", err)
		}
		items = append(items, page.Items...)
	}
	fmt.Printf("Found %d items\n", len(items))
}

Explanation

Limit does not bound what the paginator reads. This is the one that costs money. Setting Limit: aws.Int32(5) on the input above and running it over a partition of 30 items at ~60 KB each produced:

page 1: Count=5 CU=37.0   page 5: Count=5 CU=37.0
page 2: Count=5 CU=37.0   page 6: Count=5 CU=37.0
page 3: Count=5 CU=37.0   page 7: Count=0 CU=0.0
page 4: Count=5 CU=37.0   total: 30 items, 222.0 units

All thirty items came back. Limit is a page size, and the paginator's job is to keep asking until the pages run out, so the two cancel each other exactly. AWS defines it as "the maximum number of items to evaluate (not necessarily the number of matching items)" (fetched 2026-07-28). If you want the first five items, break out of the loop yourself after the first page.

Small pages cost slightly more, not less. The same partition read without Limit paginates in two pages for 220.0 units; at Limit: 5 it took seven calls for 222.0. Each page rounds its own byte total up to the next 4 KB boundary, so more pages means more rounding, plus six extra round trips of latency. Lowering Limit to "read less" gets you neither.

The loop always makes one more call than there is data. Page 7 above returned zero items. DynamoDB hands back a LastEvaluatedKey whenever Limit was reached, whether or not anything follows, and HasMorePages() believes it. So a Limit-ed paginator ends on a wasted request, and any per-page side effect (a progress bar, a batch flush, a log line) fires once against an empty page. Guard on len(page.Items).

HasMorePages() is true before the first request. It is initialised to true so the for loop enters at all, which means it is a loop condition, not a "has data" check. Calling it to decide whether to bother querying always says yes.

Errors surface per page, and a partial read is a real state. NextPage returns the same wrapped smithy error as any other call, so unwrap with errors.As against *types.ProvisionedThroughputExceededException and friends rather than matching strings. The snippet's log.Fatalf throws away the pages already collected; in a service you usually want to keep items and report how far you got.

ScanIndexForward is the only way to read a partition backwards. Set ScanIndexForward: aws.Bool(false) for descending sort-key order. There is no "sort by" for anything else: order comes from the sort key, and if you need another order you need another index. IndexName: aws.String("...") moves the whole query onto that index.

Two packages make this shorter. github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression builds the key condition and both placeholder maps from expression.Key("Artist").Equal(expression.Value("Arturo Sandoval")), which removes the hand-written #hashKey/:hashKeyValue pairs and the reserved-word risk with them. attributevalue.UnmarshalListOfMaps(page.Items, &songs) turns a page straight into []Song.

Do it visually

The two placeholder maps are the part worth generating rather than typing. The free DynamoDB Expression Builder assembles the key condition with matching ExpressionAttributeNames and ExpressionAttributeValues and emits the Go literal, so the names and values cannot drift apart.

To run queries against your own tables — key-condition form, a grid that pages as you scroll, copy the request back out as Go — download DynoTable.

References

Measured 2026-07-28 on go1.26.5 with aws-sdk-go-v2/service/dynamodb v1.62.1, against DynamoDB Local (amazon/dynamodb-local) on port 9000, over a partition of 30 items at ~60 KB each. The per-page counts and capacity readings are captured output. DynamoDB Local applies the documented rounding rules; treat the absolute units as a demonstration of the shape and measure the service before sizing.

Build this request visually

Compose this operation in the free DynamoDB Query Builder — key condition, filter, index, Limit, sort order, and a pagination loop — and copy it back as a runnable SDK v3, CLI, or boto3 program.

Open the DynamoDB Query Builder

Work with DynamoDB without the Console

A fast DynamoDB desktop client that runs the real SQL DynamoDB can’t — JOINs, GROUP BY, aggregates — with visual editing and an AI agent on your own Bedrock keys.

Free 30-day trial, no credit card — then the Free plan with no time limit.