DynamoDB Scan in Go (AWS SDK v2)

NewScanPaginator is a cursor, not a collection: it starts optimistic, ends exhausted, and takes its page size from a place the ScanInput in front of you does not show. For when to avoid Scan entirely, see Query vs. Scan.

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.NewScanPaginator(client, &dynamodb.ScanInput{
		TableName:        aws.String("Music"),
		FilterExpression: aws.String("#filter0 >= :filterValue0"),
		ExpressionAttributeNames: map[string]string{
			"#filter0": "Year",
		},
		ExpressionAttributeValues: map[string]types.AttributeValue{
			":filterValue0": &types.AttributeValueMemberN{Value: "2010"},
		},
	})

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

What HasMorePages() actually returns

Against a 600-song fixture of roughly 3.9 KB items, of which 8 match Year >= 2010 and sort last:

fresh HasMorePages():                    true    (before any request)
page 1: len(page.Items)=0  ScannedCount=271  CU=128.5  LastEvaluatedKey set
page 2: len(page.Items)=0  ScannedCount=271  CU=128.5  LastEvaluatedKey set
page 3: len(page.Items)=8  ScannedCount= 58  CU= 27.5  LastEvaluatedKey nil
exhausted HasMorePages():                false
NextPage() after exhaustion:             err = "no more pages available"

HasMorePages() is true before the first call, so the loop always fires at least once. You cannot use it to test whether a table is empty. Two pages then returned zero items while the scan was still running, which means if len(page.Items) == 0 { break } reports an empty result on a table holding 8 matches. And the paginator is single-use: after the loop the value is spent, and NextPage returns an error rather than restarting. Scanning again means calling NewScanPaginator again.

Limit lives in two places, and the option wins

Go splits the page size across the input struct and the paginator's own options. Set both and the option takes precedence:

ScanInput.Limit = 500, ScanPaginatorOptions.Limit = 25  ->  ScannedCount 25

Assigning in.Limit = aws.Int32(500) and expecting 500-item pages is a quiet miss when a functional option somewhere else already set 25. If you only touch ScanInput, the paginator honours it.

Measured 2026-07-28 against DynamoDB Local (amazon/dynamodb-local) with aws-sdk-go-v2/service/dynamodb v1.62.1 on go1.26.5.

Explanation

  • page.Items is []map[string]types.AttributeValue, not your struct. attributevalue.UnmarshalListOfMaps(items, &songs) converts the batch in one call and turns Year into a Go int; the dynamodbav struct tags control the mapping.
  • Numbers are strings on the wire. The raw value is &types.AttributeValueMemberN{Value: "2010"} — the N member holds a Go string, so strconv sits on both sides of any arithmetic unless you unmarshal into a typed struct.
  • FilterExpression runs after the read, which is why the two empty pages still cost 128.5 units each. The API reference is explicit that filtering "does not consume any additional read capacity units", and the corollary is that it does not save any either.
  • #filter0 is required, not stylistic. Year is a DynamoDB reserved word; unaliased it returns ValidationException: Invalid FilterExpression: Attribute name is a reserved keyword; reserved keyword: Year.
  • Segment / TotalSegments give each goroutine its own slice of the table, and each needs its own paginator. Parallelism cuts wall-clock time and spends the same capacity.

Do it visually

The DynamoDB query builder emits the whole program shape — filter, name and value maps, and the paginator loop — so the parts this page measures are generated rather than remembered.

To see how many items a filter really touches before you commit the scan to a service, download DynoTable and run it against the table in a grid.

References

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.