DynamoDBの Sparse Indexes を活用して 「未完了タスク」 だけを取得する方法を試してみた
スパースインデックス (Sparse Indexes) とは
Sparse Index を直訳すると、「まばらなインデックス」 となり、つまりは 「スカスカなインデックス」 というイメージです。
具体的にいうと、DynamoDBのインデックス(GSI/LSI)は、「インデックスのキーとして指定した属性(項目)を持っていないデータは、インデックスには反映されない」 という性質があり、これを利用したのがスパースインデックスになります。
やりたいこと
DynamoDB の スパースインデックスの性質を活用して、「未完了タスク」 のみを取得してみます。
例えば、以下のようなデータがあるとします:
{
userId: "001",
taskId: "002",
title: "プレゼン資料作成",
isCompleted: false // 完了/未完了を boolean で管理したい
}
これをシンプルに、isCompleted をインデックスキーに指定すれば解決!
と思いきや、DynamoDB のインデックスキーには、STRING,NUMBER,BINARY の三つの方しか使用できないという制約があるため、boolean 値で 「完了/未完了」 といった値でインデックスを貼ることができません。
そこで、今回はスパースインデックスの性質を使って、解決してみようという試みです。
完成した全体のコードはこちらにあります。
環境
AWS リソースの管理には、記事の読者が環境を再現できるように、CDK を使って作っていきます。
動作確認は、アプリケーション開発のイメージが湧きやすいように、Lambda + TypeScript で作ります。
検証
データ構造としては、「未完了タスク」 にのみpendingAt というフィールドを設定します。
// pendingAt が設定されている、「プレゼン資料作成」 「メール返信」 のみを取ってこれれば成功です。
// 例
{
"userId": "001",
"title": "プレゼン資料作成",
"pendingAt": 1768631797000,
....
},
{
"userId": "001",
"title": "AWS本を読む",
....
},
{
"userId": "001",
"title": "メール返信",
"pendingAt": 1768631799000
...
}
テーブルの作成
AWS CDKで、検証用の todo テーブルを作成、未完了タスク取得用のインデックスを設定します。
import { Construct } from "constructs";
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
import { RemovalPolicy } from "aws-cdk-lib";
export class TodosTable extends Construct {
public readonly todosTable: dynamodb.Table;
constructor(scope: Construct, id: string) {
super(scope, id);
this.todosTable = new dynamodb.Table(this, "TodoTable", {
tableName: "Todos",
partitionKey: {
name: "userId",
type: dynamodb.AttributeType.STRING,
},
sortKey: {
name: "createdAt",
type: dynamodb.AttributeType.NUMBER,
},
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
removalPolicy: RemovalPolicy.DESTROY,
});
this.todosTable.addLocalSecondaryIndex({
indexName: "user-incomplete-lsi",
sortKey: {
name: "pendingAt",
type: dynamodb.AttributeType.NUMBER,
},
projectionType: dynamodb.ProjectionType.ALL,
});
}
}
lambda のコード
今回コードはメインではないため、シンプルに未完了のタスクのみ取得する処理を実装します。
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb";
interface Event {
userId: string;
}
const docClient = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.TABLE_NAME!;
// 直接未完了をクエリ
export const handler = async ({ userId }: Event) => {
const result = await docClient.send(
new QueryCommand({
TableName: TABLE,
IndexName: "user-incomplete-lsi",
KeyConditionExpression: "userId = :userId",
ExpressionAttributeValues: { ":userId": userId },
ConsistentRead: true,
}),
);
return {
count: result.Count,
items: result.Items,
};
};
検証データを投入
投入用のshell script
#!/usr/bin/env bash
TABLE_NAME="${TABLE_NAME:-Todos}"
REGION="${AWS_REGION:-ap-northeast-1}"
# タイムスタンプ(ミリ秒)を作成
NOW=$(($(date +%s) * 1000))
TS_001_1=$((NOW - 10000))
TS_001_2=$((NOW - 9000))
TS_001_3=$((NOW - 8000))
TS_002_1=$((NOW - 7000))
TS_002_2=$((NOW - 6000))
TS_003_1=$((NOW - 5000))
TS_003_2=$((NOW - 4000))
TS_003_3=$((NOW - 3000))
TS_COMPLETED_1=$((NOW - 5000))
TS_COMPLETED_2=$((NOW - 3000))
TS_COMPLETED_3=$((NOW - 1000))
# BatchWrite で一括投入(未完了は pendingAt を設定、完了は completedAt を設定)
REQUEST_JSON=$(cat <<JSON
{
"$TABLE_NAME": [
{
"PutRequest": {
"Item": {
"userId": { "S": "001" },
"createdAt": { "N": "$TS_001_1" },
"id": { "S": "001" },
"title": { "S": "プレゼン資料作成" },
"description": { "S": "来週の営業会議用" },
"category": { "S": "work" },
"pendingAt": { "N": "$TS_001_1" }
}
}
},
{
"PutRequest": {
"Item": {
"userId": { "S": "001" },
"createdAt": { "N": "$TS_001_2" },
"id": { "S": "002" },
"title": { "S": "メール返信" },
"description": { "S": "クライアントへの返信完了" },
"category": { "S": "work" },
"completedAt": { "N": "$TS_COMPLETED_1" }
}
}
},
{
"PutRequest": {
"Item": {
"userId": { "S": "001" },
"createdAt": { "N": "$TS_001_3" },
"id": { "S": "003" },
"title": { "S": "AWS本を読む" },
"description": { "S": "認定試験の勉強" },
"category": { "S": "personal" },
"pendingAt": { "N": "$TS_001_3" }
}
}
},
{
"PutRequest": {
"Item": {
"userId": { "S": "002" },
"createdAt": { "N": "$TS_002_1" },
"id": { "S": "004" },
"title": { "S": "コードレビュー" },
"description": { "S": "新機能のPRレビュー" },
"category": { "S": "work" },
"pendingAt": { "N": "$TS_002_1" }
}
}
},
{
"PutRequest": {
"Item": {
"userId": { "S": "002" },
"createdAt": { "N": "$TS_002_2" },
"id": { "S": "005" },
"title": { "S": "食材購入" },
"description": { "S": "週末の買い物完了" },
"category": { "S": "shopping" },
"completedAt": { "N": "$TS_COMPLETED_2" }
}
}
},
{
"PutRequest": {
"Item": {
"userId": { "S": "003" },
"createdAt": { "N": "$TS_003_1" },
"id": { "S": "006" },
"title": { "S": "ジムに行く" },
"description": { "S": "筋トレ" },
"category": { "S": "personal" },
"pendingAt": { "N": "$TS_003_1" }
}
}
},
{
"PutRequest": {
"Item": {
"userId": { "S": "003" },
"createdAt": { "N": "$TS_003_2" },
"id": { "S": "007" },
"title": { "S": "技術書を買う" },
"description": { "S": "AWS認定試験の本" },
"category": { "S": "shopping" },
"pendingAt": { "N": "$TS_003_2" }
}
}
},
{
"PutRequest": {
"Item": {
"userId": { "S": "003" },
"createdAt": { "N": "$TS_003_3" },
"id": { "S": "008" },
"title": { "S": "会議資料準備" },
"description": { "S": "明日の会議用資料完成" },
"category": { "S": "work" },
"completedAt": { "N": "$TS_COMPLETED_3" }
}
}
}
]
}
JSON
)
echo "Writing batch items to table '$TABLE_NAME' in region '$REGION'..."
aws dynamodb batch-write-item --region "$REGION" --request-items "$REQUEST_JSON"
echo "Done."
無事データが挿入できました

lambdaを実行して、確かめてみる
userIdが 001 の未完了タスクを取得してみます。
aws lambda invoke \
--function-name todo-function \
--payload '{"userId": "001"}' \
--cli-binary-format raw-in-base64-out \
response.json
レスポンスは、生成される、response.json に格納されるので中身を確認します。
無事に未完了タスクのみ取って来れています!
{
"count": 2,
"items": [
{
"userId": "001",
"category": "work",
"createdAt": 1768631797000,
"description": "来週の営業会議用",
"id": "001",
"title": "プレゼン資料作成",
"pendingAt": 1768631797000
},
{
"userId": "001",
"category": "personal",
"createdAt": 1768631799000,
"description": "認定試験の勉強",
"id": "003",
"title": "AWS本を読む",
"pendingAt": 1768631799000
}
]
}
コンソール上で確認しても、完了時間(completedAt) が存在しない、idが 001,003 の二つが取ってこれたことがわかります。

おわり
今回は、データの状態が、ON/OFF みたいな2パターンの時、シンプルに boolen で定義したいけど、indexが貼れない。というときに使える小技が学べました。
以前、無理にやろうとして、true/false を文字列で保存したことがありました。
その結果、アプリケーション内での通常の boolean フラグと、インデックス用の文字列型の真偽値が混在し、コードの可読性と保守性が低下してしまうこととなってしまいました。
const isComplete = true; // 通常のboolean
const isActive = 'true'; // インデックス用の文字列
ですが、今回学んだ方法を活用することで、未完了(false) 状態のデータを取得できるインデックスが貼れるようになります!
DynamoDB の特性を活かした小技として、どなたかの参考になれば幸いです。






