dynotable-orm: a type-safe DynamoDB ORM for TypeScript

What is dynotable-orm?

dynotable-orm is a type-safe DynamoDB ORM for TypeScript on Node.js. You define a model from a Zod 4 schema, or from any Standard Schema, and the compiler checks every key, update path, condition value and index name. It keeps dynamoose's call shapes, and a whole-item write stores the same item as dynamoose 4.2.0. A codemod moves dynamoose code over.

Why dynotable-orm

  • dynamoose call shapes. get, create, update, query, scan, conditions, transactions and batches keep the shapes that dynamoose users know. Your schema gives the types: a wrong key, a misspelled path or a value of the wrong type does not compile.
  • The same stored items as dynamoose 4.2.0 on whole-item writes. Both store a Date as epoch milliseconds by default. The library's equivalence suite runs each case through both libraries on DynamoDB Local: every create, put, batch, transaction write and top-level update stores the same bytes, and each library reads the other's items. A nested update path is not the same: for $SET: {'a.b': 1}, dynamoose 4.2.0 writes a top-level attribute named a.b, and dynotable-orm writes the field b inside a. The migration guide lists every difference seen, and sharing a table during a rollout says what to move first.
  • Any Standard Schema validates. The Zod 4 adapter adds keys, indexes, Date storage and per-path checks on updates. Refinements run on every whole-item write.
  • Fast. In a CPU benchmark on a stub client, measured 2026-11-06, a create, get or update on a schema of about 60 attributes is 93 to 854 times faster than dynamoose 4.2.0, and as fast as hand-written AWS SDK code or faster. On a 5-attribute schema, hand-written SDK code is faster on some calls. The numbers are CPU only, with no network time: see the benchmarks.
  • A codemod. dynotable-orm-codemod moves dynamoose code and schemas over, and marks what it cannot move with a TODO.
Bar chart, calls per second on a 60-attribute schema, higher is better. Create: dynotable-orm 23,300, hand-written AWS SDK 20,100, dynamoose 256. Get: dynotable-orm 107,000, hand-written AWS SDK 84,300, dynamoose 281. Update: dynotable-orm 69,400, hand-written AWS SDK 66,800, dynamoose 78.8. Query of 100 items: dynotable-orm 1,070, hand-written AWS SDK 836, dynamoose 3.76.

Install

npm install dynotable-orm @aws-sdk/client-dynamodb zod

dynotable-orm needs Node 24 or later. @aws-sdk/client-dynamodb v3 is a peer dependency. zod (v4) is optional: without it, give the keys and indexes as model options. The package ships ES module and CommonJS builds with their types.

A 30-second example

import {DynamoDBClient} from '@aws-sdk/client-dynamodb';
import {model, setClient} from 'dynotable-orm';
import {extendZod} from 'dynotable-orm/zod';
import {z} from 'zod';

extendZod(z); // once, before you build a schema

setClient(new DynamoDBClient({region: 'eu-west-1'}));

export const User = model(
  'User',
  z.object({
    id: z.string().hashKey(),
    email: z.email().index({name: 'byEmail'}),
    plan: z.enum(['free', 'team']).default('free'),
    logins: z.number().default(0),
    createdAt: z.date().optional(),
    updatedAt: z.date().optional()
  }),
  {timestamps: true}
);

await User.create({id: 'u1', email: 'ada@example.com'});

const user = await User.get('u1'); // the item, or undefined

await User.update(
  {id: 'u1'},
  {plan: 'team', $ADD: {logins: 1}},
  {condition: User.condition().where('plan').eq('free')}
);

const found = await User.query('email').eq('ada@example.com').exec();
console.log(user?.plan, found.count);
  • create writes only when the key is not stored yet. Pass {overwrite: true} to replace an item.
  • get resolves to the item, typed from the schema, or to undefined.
  • update takes plain fields as an implicit $SET, next to $ADD, $REMOVE and $DELETE. A wrong path or value type does not compile.
  • query('email') picks the byEmail index from the condition. No index name is written by hand.
  • Dates are stored as epoch milliseconds by default, as in dynamoose. For a table that holds ISO strings or seconds, read the Date storage trap before you point a model at it.

Getting started walks through the same steps with a local table, and DynamoDB with TypeScript builds a complete small app.

Packages

packagewhat it is
dynotable-ormthe library: models, queries, conditions, transactions, errors
dynotable-orm/zodthe Zod 4 adapter: field marks and extendZod()
dynotable-orm-codemodthe command that migrates dynamoose code and schemas

All three are MIT licensed. The source is on GitHub: dynotable/dynamodb-orm.

When to use something else

dynotable-orm maps one model to one table, named after the model. It has no entity type attribute and no collections, so it is not the tool to put many entity types in one table: for that single-table design, ElectroDB and DynamoDB-Toolbox are built around it. For one table and a few access patterns, the AWS SDK alone can be enough. Should you use an ORM with DynamoDB? compares the options honestly.

The documentation

Getting Started

Guides

Migrating

Reference

To look at the items a model stores, open the table in DynoTable, a desktop DynamoDB GUI.

Updated

Start readingGetting started with dynotable-orm in Node.js