Skip to content

Example: Scan Operations

A product and review catalog that demonstrates scan operations — reading all items from a table without targeting a specific partition key. Scans are the broadest read operation in DynamoDB, and effect-dynamodb makes them composable with the same BoundQuery combinators used for index queries.

What you’ll learn:

  • db.entities.Entity.scan() — full-table scan returning a BoundQuery
  • BoundQuery combinators: .filter(), .limit(), .consistentRead() on scans
  • Terminals: .collect(), .fetch(), .paginate() for executing scans
  • Entity-type isolation — scans automatically filter by __edd_e__ so each entity only sees its own items
  • When to use scan vs query

Two entity types sharing a single table — products and reviews:

models.ts
class Product extends Schema.Class<Product>("Product")({
productId: Schema.String,
name: Schema.NonEmptyString,
category: Schema.String,
price: Schema.Number,
inStock: Schema.Boolean,
}) {}
class Review extends Schema.Class<Review>("Review")({
reviewId: Schema.String,
productId: Schema.String,
rating: Schema.Number,
comment: Schema.String,
}) {}

Both entities share one table with GSI indexes for category/product lookups:

entities.ts
const AppSchema = DynamoSchema.make({ name: "scan-demo", version: 1 })
const Products = Entity.make({
model: Product,
entityType: "Product",
primaryKey: {
pk: { field: "pk", composite: ["productId"] },
sk: { field: "sk", composite: [] },
},
indexes: {
byCategory: {
name: "gsi1",
pk: { field: "gsi1pk", composite: ["category"] },
sk: { field: "gsi1sk", composite: ["productId"] },
},
},
timestamps: true,
})
const Reviews = Entity.make({
model: Review,
entityType: "Review",
primaryKey: {
pk: { field: "pk", composite: ["reviewId"] },
sk: { field: "sk", composite: [] },
},
indexes: {
byProduct: {
name: "gsi1",
pk: { field: "gsi1pk", composite: ["productId"] },
sk: { field: "gsi1sk", composite: ["reviewId"] },
},
},
timestamps: true,
})
const MainTable = Table.make({ schema: AppSchema, entities: { Products, Reviews } })

Note that both entities overload GSI1 — Products use it for category lookups while Reviews use it for product lookups. This is standard single-table design.


seed.ts
yield* db.entities.Products.put({
productId: "p-1",
name: "Wireless Mouse",
category: "electronics",
price: 29.99,
inStock: true,
})
yield* db.entities.Products.put({
productId: "p-2",
name: "Mechanical Keyboard",
category: "electronics",
price: 89.99,
inStock: true,
})
yield* db.entities.Products.put({
productId: "p-3",
name: "USB-C Cable",
category: "accessories",
price: 9.99,
inStock: false,
})
yield* db.entities.Products.put({
productId: "p-4",
name: "Monitor Stand",
category: "accessories",
price: 49.99,
inStock: true,
})
yield* db.entities.Reviews.put({
reviewId: "r-1",
productId: "p-1",
rating: 5,
comment: "Great mouse!",
})
yield* db.entities.Reviews.put({
reviewId: "r-2",
productId: "p-2",
rating: 4,
comment: "Good keyboard",
})

The table now has 6 items: 4 products and 2 reviews.


db.entities.Products.scan() returns a BoundQuery that reads the entire table. Call .collect() to execute it. Despite sharing a table with reviews, the scan only returns product items — the ORM automatically adds a FilterExpression on __edd_e__:

const allProducts = yield* db.entities.Products.scan().collect()

Scans support all BoundQuery combinators, including .filter(). Chain combinators on the BoundQuery before calling .collect(). The filter is applied server-side after items are read, reducing network transfer:

const inStockProducts = yield* db.entities.Products.scan().filter({ inStock: true }).collect()

Note that filters do not reduce read capacity — DynamoDB still reads every item in the table. Use queries with partition keys when you need efficient reads.

.limit() controls how many items DynamoDB evaluates per request. Combined with .collect(), it caps the total results:

const firstTwo = yield* db.entities.Products.scan().limit(2).collect()

.consistentRead() passes ConsistentRead: true to DynamoDB, ensuring you read the most recent data. This costs 2x the read capacity of eventually-consistent reads:

const consistent = yield* db.entities.Products.scan().consistentRead().collect()

The key feature of scans in a single-table design: each entity’s scan only returns its own items. The __edd_e__ discriminator attribute is automatically included in the FilterExpression:

const productScan = yield* db.entities.Products.scan().collect()
const reviewScan = yield* db.entities.Reviews.scan().collect()

Even though both scans read the same physical table, each returns only items matching its entity type.


ScanQuery
ReadsEntire table (or index)Single partition key
CostProportional to table sizeProportional to items in partition
Use caseAnalytics, admin tools, data exportApplication access patterns
FilterExpressionReduces network transfer, NOT read capacitySame behavior

Prefer queries for application access patterns. Use scans for administrative operations, data migrations, or when you genuinely need all items of a given entity type.


The complete runnable example is at examples/scan.ts in the repository.

Start DynamoDB Local:

Terminal window
docker run -d -p 8000:8000 amazon/dynamodb-local

Run the example:

Terminal window
npx tsx examples/scan.ts
main.ts
const AppLayer = Layer.mergeAll(
DynamoClient.layer({
region: "us-east-1",
endpoint: "http://localhost:8000",
credentials: { accessKeyId: "local", secretAccessKey: "local" },
}),
MainTable.layer({ name: "scan-demo-table" }),
)
const main = program.pipe(Effect.provide(AppLayer))
Effect.runPromise(main).then(
() => console.log("\nDone."),
(err) => console.error("\nFailed:", err),
)

ConceptHow it’s used
db.entities.Entity.scan()Returns a BoundQuery — composable with all BoundQuery combinators
.collect() / .paginate()Terminals on BoundQuery — execute a scan (or query)
.filter()Server-side FilterExpression on scans — reduces network transfer
.limit()Caps items evaluated per DynamoDB request
.consistentRead()Strongly consistent reads at 2x RCU cost
Entity-type isolation__edd_e__ filter ensures scans return only the matching entity type
Scan vs QueryScans read the whole table; queries target a partition — prefer queries for app patterns