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 25Assigning 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 honors 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.Itemsis[]map[string]types.AttributeValue, not your struct.attributevalue.UnmarshalListOfMaps(items, &songs)converts the batch in one call and turnsYearinto a Goint; thedynamodbavstruct tags control the mapping.- Numbers are strings on the wire. The raw value is
&types.AttributeValueMemberN{Value: "2010"}— theNmember holds a Gostring, sostrconvsits on both sides of any arithmetic unless you unmarshal into a typed struct. FilterExpressionruns 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.#filter0is required, not stylistic.Yearis a DynamoDB reserved word; unaliased it returnsValidationException: Invalid FilterExpression: Attribute name is a reserved keyword; reserved keyword: Year.Segment/TotalSegmentsgive 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.
Related examples
- DynamoDB Scan in Java — the same scan with AWS SDK for Java 2.x.
- DynamoDB Query in Go — the cheaper read you should usually reach for.
- Query vs. Scan — when (rarely) a
Scanis justified. - Why is my DynamoDB Scan slow and expensive? — the cost model and how to avoid it.
- DynamoDB ProvisionedThroughputExceededException — what a full-table scan does to a provisioned table's capacity.
- DynamoDB ThrottlingException — the other throttle, and how exponential backoff handles it.