DynamoDB keys and secondary indexes with dynotable-orm
How do you define DynamoDB keys and indexes in TypeScript?
Mark the key fields of the schema with .hashKey() and .rangeKey(), and each indexed field with
.index(), or give the same settings as model options. dynotable-orm types every key you pass from
these marks, picks the index a query can use, checks index names at compile time, and builds the
CreateTable input with createTableInput().
A model maps to one table, named after the model. This page covers the primary key, the secondary indexes, how a query picks one, and the table definition. The Zod adapter covers the marks themselves.
The primary key
A table's primary key is a hash key alone, or a hash key and a range key: a composite primary key. Every call that names one item takes the key as an object with the key attributes. A model with only a hash key also takes the bare value:
import {model} from 'dynotable-orm';
import {extendZod} from 'dynotable-orm/zod';
import {z} from 'zod';
extendZod(z);
export const User = model('User', z.object({id: z.string().hashKey(), name: z.string()}));
export const Message = model(
'Message',
z.object({
roomId: z.string().hashKey(),
sentAt: z.date().rangeKey(),
author: z.string(),
text: z.string()
})
);
await User.get('u1'); // a bare key: the model has only a hash key
await User.get({id: 'u1'}); // the same key as an object
const sentAt = new Date('2026-11-06T09:30:00Z');
await Message.get({roomId: 'general', sentAt}); // a composite key needs both attributes
console.log(User.hashKey, Message.rangeKey, Message.tableName); // 'id' 'sentAt' 'Message'- The key type comes from the schema.
Message.get({roomId: 'general'})does not compile: the range key is missing. A value of the wrong type does not compile either. - A key goes through the field's schema. With the Zod adapter, each key attribute runs through its
field's transforms, then the whole-item
sethook (the model'ssetoption, a function over the whole item) runs once on the whole key. ADatetakes its stored form, here a number of epoch milliseconds. Pass{rawKey: true}toget,updateordeleteto skip the transforms and the hook. - A missing key attribute throws. A key without one of its attributes throws an
ExpressionError(the key of 'Message' has no 'sentAt') before sending, also when the types were bypassed. - A model must name its hash key.
model()throws aConfigErrorwhen no mark and no option names one. dynamoose took the first attribute by default; dynotable-orm does not guess. - A key field must be declared. With the Zod adapter, a key that the schema does not declare throws a
ConfigErroratmodel(), because Zod would strip it from every item.
M.hashKey and M.rangeKey hold the attribute names, and M.tableName the table name. The table name
is ${prefix}${name}${suffix}, read on every call, so a later setDefaults({prefix}) applies to models
that already exist. A model's own prefix and suffix options override the defaults.
Secondary indexes
.index() on a field, or the indexes model option, declares a secondary index. Without a type, an
index is global and hashed on the field. With type: 'local', it is a local index: the table's hash key,
with the field as its range key.
import {model} from 'dynotable-orm';
import {extendZod} from 'dynotable-orm/zod';
import {z} from 'zod';
extendZod(z);
export const Order = model(
'Order',
z.object({
customerId: z.string().hashKey(),
orderId: z.string().rangeKey(),
// A global index, sorted by placedAt, that projects only the keys and `total`.
status: z
.enum(['open', 'paid'])
.index({name: 'byStatus', rangeKey: 'placedAt', project: ['total']}),
// A local index: same hash key as the table, sorted by placedAt.
placedAt: z.date().index({name: 'byPlacedAt', type: 'local'}),
// A sparse global index: only orders with a couponCode are in it.
couponCode: z.string().optional().index({name: 'byCoupon'}),
total: z.number(),
note: z.string().optional()
})
);
const open = await Order.query('status').eq('open').using('byStatus').exec();
console.log(open[0]?.total); // projected
// @ts-expect-error: byStatus does not project `note`
console.log(open[0]?.note);| setting | default | what it does |
|---|---|---|
name | <field>GlobalIndex, <field>LocalIndex | the index name. Typed: using() takes only a declared name |
type | 'global' | 'local' needs a table range key, or model() throws a ConfigError |
rangeKey | none | the sort key of a global index. A local index is always sorted by its own field |
project | true | true is ALL, false is KEYS_ONLY, an array is INCLUDE with those attributes |
The projection is in the types too. using('byStatus') narrows each item to the table keys, the index
keys and the projected attributes, so a field the index does not hold does not compile.
Index projections explains the cost trade-off.
An item without the index's key attribute is left out of the index by DynamoDB: a sparse
index. An optional indexed field, as couponCode above, gives one.
Several indexes on one field take an array: .index([{name: 'byEmail'}, {name: 'byEmailLocal', type: 'local'}]). GSI vs LSI compares the two kinds; a local index can only be
created with the table, so decide early.
Which index a query uses
query() needs an equality on a hash key: the table's or an index's. Without using(), the library picks
the index from the conditions:
- It takes every index whose hash key has an equality in the conditions, global indexes first, then local ones, then the table.
- It picks the first of those whose range key is also in the conditions.
- Else the table, if the table's hash key has an equality.
- Else the first one.
import {model} from 'dynotable-orm';
import {extendZod} from 'dynotable-orm/zod';
import {z} from 'zod';
extendZod(z);
const Order = model(
'Order',
z.object({
customerId: z.string().hashKey(),
orderId: z.string().rangeKey(),
status: z.enum(['open', 'paid']).index({name: 'byStatus', rangeKey: 'placedAt'}),
placedAt: z.date().index({name: 'byPlacedAt', type: 'local'})
})
);
await Order.query('customerId').eq('c1').exec(); // the table
await Order.query('status').eq('open').exec(); // byStatus: its hash key has the equality
await Order.query('customerId').eq('c1').where('placedAt').gt(new Date('2026-11-01')).exec(); // byPlacedAt
await Order.query('customerId').eq('c1').where('status').eq('open').exec(); // the table, status is a filter
await Order.query('status').eq('open').using('byStatus').exec(); // named: no choice madeThe fourth query has equalities on both customerId and status. No range key is in the conditions, so
the table wins, and status becomes a filter expression. Name the index with using() whenever the
choice matters. A query with no equality on any hash key throws an ExpressionError (no index fits the query). Query and scan covers the key condition and the filter.
Creating the table: createTableInput()
dynotable-orm never creates or changes a table. createTableInput() returns the CreateTable input that
matches the model: its key schema, its indexes and their projections, with on-demand billing. Send it
with the SDK, for a local or test table:
import {CreateTableCommand, DynamoDBClient} from '@aws-sdk/client-dynamodb';
import {model} from 'dynotable-orm';
import {extendZod} from 'dynotable-orm/zod';
import {z} from 'zod';
extendZod(z);
const Order = model(
'Order',
z.object({
customerId: z.string().hashKey(),
orderId: z.string().rangeKey(),
status: z
.enum(['open', 'paid'])
.index({name: 'byStatus', rangeKey: 'placedAt', project: false}),
placedAt: z.date(),
total: z.number()
})
);
const input = Order.createTableInput();
console.log(input.AttributeDefinitions);
// [{AttributeName: 'customerId', AttributeType: 'S'}, {AttributeName: 'orderId', AttributeType: 'S'},
// {AttributeName: 'status', AttributeType: 'S'}, {AttributeName: 'placedAt', AttributeType: 'S'}]
console.log(input.GlobalSecondaryIndexes?.[0]?.Projection); // {ProjectionType: 'KEYS_ONLY'}
const client = new DynamoDBClient({
region: 'eu-west-1',
endpoint: 'http://localhost:8000',
credentials: {accessKeyId: 'local', secretAccessKey: 'local'}
});
await client.send(new CreateTableCommand(input));- Only key attributes are defined. DynamoDB needs
AttributeDefinitionsfor the table and index keys alone, sototalis not listed. - The attribute type comes from the schema. A string or enum field is
Sand a number fieldN. A Date key follows its storage:Sfor'iso',Nfor'milliseconds'or'seconds'. - A type it cannot tell throws. A key field that is not one string or number type, such as a union of
a string and a number, throws a
ConfigError. So does every non-Date key of a schema that is not Zod: write thatCreateTableinput yourself. - The billing mode is
PAY_PER_REQUEST. For provisioned capacity, change the input before you send it.
In production, own the tables in infrastructure code (CloudFormation, CDK or Terraform), and use
createTableInput() as a check that the model and the table agree.
A sort key from several fields
A composite sort key such as 2026-11#paid comes from a combined
field: combine(z.string(), ['month', 'status'], '#').rangeKey(). The library builds it on every
whole-item write. Because it is the range key, an update cannot change its sources: an update that
writes month or status throws an ExpressionError. Keep those fields out of the key, or move the
item to its new key.
A new sort key is a new item. create(item, {overwrite: true}) writes the item at the new key and leaves
the old item in the table. Delete the old key in the same transaction, so one write does not happen
without the other:
import {model, transaction} from 'dynotable-orm';
import {combine, extendZod} from 'dynotable-orm/zod';
import {z} from 'zod';
extendZod(z);
const Order = model(
'Order',
z.object({
customerId: z.string().hashKey(),
month: z.string(),
status: z.enum(['open', 'paid']),
monthStatus: combine(z.string(), ['month', 'status'], '#').rangeKey(),
total: z.number()
})
);
export async function markPaid(customerId: string, month: string) {
const order = await Order.get({customerId, monthStatus: `${month}#open`});
if (!order) throw new Error('no open order');
await transaction([
Order.transaction.delete({customerId, monthStatus: order.monthStatus}),
// Conditional on the new key not being stored yet.
Order.transaction.create({...order, status: 'paid'})
]);
}The library builds monthStatus again from the new status, so 2026-11#open becomes 2026-11#paid.
The Zod adapter has the combine() rules.
One model per table
A model names its own table, and there is no entity type attribute. To keep several entity types in one table with single-table design, use a library built for it: ElectroDB or DynamoDB-Toolbox. Should you use an ORM with DynamoDB? compares them.
Next steps
- Query and scan: key conditions, filters and pagination.
- The
IndexDefinitionandcreateTableInput()reference.