Skip to content

DynamoDB

DynamoDB is a database of items looked up by key. A read or write by key takes a few milliseconds whether the table holds a hundred items or a billion, and you never size a server for it: by default you pay per request.

That speed comes from one rule: you can only look items up by their keys. So designing a table starts from the questions you’ll ask it.

An item is a set of attributes, like a row without a fixed schema. Every item has a partition key, and DynamoDB stores items by it: all items with the same partition key live together, and different partition keys spread across many machines.

A table can also have a sort key. Then the partition key and the sort key together identify an item, and the items in one partition are kept in order by the sort key.

Orders · DynamoDB TableQuery pk = "user#sam", sk begins_with "order#"
pksktotal
user#anaorder#0301$8
user#anaprofile·
user#samorder#0104$20
user#samorder#0211$35
user#samprofile·
Items with the same partition key live together, sorted by sort key. Sam's orders are one query: one partition, a range of sort keys.

Two kinds of read are fast. Get one item by its full key. Query one partition, optionally narrowing by a range of sort keys: “Sam’s orders in 2026”. Anything else means a scan, which reads the whole table. So list the reads your app needs before you choose the keys, and pick keys that turn each one into a get or a query.

Name the key attributes and their types. "S" is a string, "N" a number, "B" binary:

src/orders.ts
import * as AWS from "@alchemy.run/aws";
export const Orders = AWS.DynamoDB.Table("Orders", {
partitionKey: "pk",
sortKey: "sk",
attributes: { pk: "S", sk: "S" },
});

attributes lists only the attributes used in keys and indexes. Every other attribute of an item is free-form.

The table is created on-demand: there’s no capacity to set, and you pay per read and write. Alchemy names it from your app, stage and id, so each stage gets its own table.

Bind DynamoDB.PutItem in a runtime. Its role gets dynamodb:PutItem on this table only:

src/api.ts
const putItem = yield* AWS.DynamoDB.PutItem(Orders);

An item is written in DynamoDB’s own attribute format, where each value says its type:

src/api.ts
yield* putItem({
Item: {
pk: { S: "user#sam" },
sk: { S: "order#2026-02-11" },
total: { N: "42" },
},
});

The bindings pass this format through unchanged, so what you write is exactly what the API receives. If you’d rather work with plain objects, wrap the client in a function that converts them.

A put replaces any item with the same key. To change some attributes and keep the rest, use DynamoDB.UpdateItem. To write only if the item doesn’t exist yet, add a ConditionExpression, and catch ConditionalCheckFailedException when it does.

Bind DynamoDB.GetItem and read by the full key:

src/api.ts
const getItem = yield* AWS.DynamoDB.GetItem(Orders);
const { Item } = yield* getItem({
Key: { pk: { S: "user#sam" }, sk: { S: "profile" } },
});

A missing item isn’t an error: Item is just undefined.

Each binding has a client Layer named after it. Provide the ones you bind to the runtime, merged into one:

src/api.ts
Effect.provide(Layer.mergeAll(AWS.DynamoDB.GetItemHttp, AWS.DynamoDB.PutItemHttp))

Bind DynamoDB.Query to read many items from one partition. The key condition names the partition and, optionally, a range of sort keys:

src/api.ts
const query = yield* AWS.DynamoDB.Query(Orders);
const { Items } = yield* query({
KeyConditionExpression: "pk = :pk AND begins_with(sk, :prefix)",
ExpressionAttributeValues: {
":pk": { S: "user#sam" },
":prefix": { S: "order#2026" },
},
});

Items come back in sort-key order, a page at a time. This is why the sort key above starts with order# and a date: one query returns a user’s orders for a year, already sorted.

Some reads don’t fit the table’s keys: “every order placed today”, across all users. A global secondary index answers them. It’s a copy of the table, kept in sync for you, with its own partition and sort key:

src/orders.ts
export const Orders = AWS.DynamoDB.Table("Orders", {
partitionKey: "pk",
sortKey: "sk",
attributes: { pk: "S", sk: "S", day: "S" },
globalSecondaryIndexes: [
{ indexName: "byDay", partitionKey: "day", sortKey: "sk", projection: { ProjectionType: "ALL" } },
],
});

Query it by passing IndexName: "byDay" to the same query client. An index is updated shortly after each write, so a query on it may briefly miss the newest items.

Each index costs storage and a write for every item it covers. Add one per question that needs it, not one per attribute.

Sessions, caches and one-time codes should disappear on their own. Name an attribute that holds an expiry time, in seconds since 1970, and DynamoDB deletes each item some time after it passes:

src/sessions.ts
export const Sessions = AWS.DynamoDB.Table("Sessions", {
partitionKey: "id",
attributes: { id: "S" },
timeToLiveSpecification: { AttributeName: "expiresAt", Enabled: true },
});

Deletion can lag the expiry by a while, so check expiresAt when you read too.

A table can publish every change to its items, in order per item, as a stream. A Function consumes it to keep a search index current, send notifications or update totals. See DynamoDB Streams.

Most settings change in place: indexes, TTL, tags, streams and recovery are updated on the existing table. Changing the partition key, the sort key or a local secondary index replaces the table, because DynamoDB can’t change them, and replacing a table loses its items.

Protect a table that holds data you can’t recreate. Turn on point-in-time recovery to restore it to any second in the last 35 days, and deletion protection to refuse a delete until you turn it off:

src/orders.ts
export const Orders = AWS.DynamoDB.Table("Orders", {
partitionKey: "pk",
sortKey: "sk",
attributes: { pk: "S", sk: "S" },
pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
deletionProtectionEnabled: true,
});