A dynamoose alternative for TypeScript: dynotable-orm

What is a good alternative to dynamoose?

For TypeScript code that should keep the dynamoose API, dynotable-orm. It keeps dynamoose's call shapes, a whole-item write stores the same item as dynamoose 4.2.0, and a codemod moves the code over. The difference is the types: they come from your schema, so keys, update paths, condition values and index names are checked at compile time.

dynamoose is maintained, and for JavaScript code it may be all you need. This page is for teams whose dynamoose code is TypeScript, and who want the compiler to check it. The dynamoose facts below are from its docs and from the types it publishes in version 4.2.0, the latest on npm as of 2026-11-06.

Where dynamoose's TypeScript support stops

The dynamoose TypeScript docs say that its "TypeScript Support is in beta", and that this support "does not have typings or contracts between your Schema and Items you create". The types in dynamoose 4.2.0 show what that means:

  • dynamoose.model<T>(name, schema) takes the item type as a type parameter that you write. Without one, it is AnyItem. The schema does not produce it, so the two can drift apart.
  • A key is string | number | {[attribute: string]: string | number}.
  • A condition's where(key: string) takes any string, and eq(value: any) any value.

So this code compiles, with each mistake left for runtime:

import dynamoose from 'dynamoose';

const User = dynamoose.model(
  'User',
  new dynamoose.Schema({id: {type: String, hashKey: true}, plan: String, logins: Number})
);

await User.get({userId: 'u1'}); // the key attribute is `id`
await User.update({id: 'u1'}, {plna: 'team'}); // a misspelled attribute
await User.query('plan').eq(42).exec(); // a number for a string attribute

The same model in dynotable-orm, where each of these lines is a compile error:

import {model} from 'dynotable-orm';
import {extendZod} from 'dynotable-orm/zod';
import {z} from 'zod';

extendZod(z);

const User = model(
  'User',
  z.object({id: z.string().hashKey(), plan: z.string().optional(), logins: z.number().default(0)})
);

// @ts-expect-error: the key attribute is `id`
await User.get({userId: 'u1'});

// @ts-expect-error: the schema has no attribute `plna`
await User.update({id: 'u1'}, {plna: 'team'});

// @ts-expect-error: plan is a string
await User.query('plan').eq(42).exec();

@ts-expect-error marks each line, so this block compiles only while the error is still caught. The item type is ItemOf<typeof User>, read from the schema, and the same schema validates every whole-item write at runtime. DynamoDB with TypeScript shows the full set of checks in a complete app.

What stays the same

  • Stored items. For the same schema and input, a whole-item write and an update of top-level fields store the same bytes as dynamoose 4.2.0, and each library reads the other's items. Both store a Date as epoch milliseconds by default. The library's equivalence suite checks this on DynamoDB Local, through both libraries. Nested update paths differ: see sharing a table during a rollout. The migration guide lists every difference seen.
  • Table names. ${prefix}${name}${suffix}, with setDefaults({prefix, suffix}) in place of dynamoose.Table.defaults.set().
  • Call shapes. get(key), create(item), update(key, changes) with $SET, $ADD, $REMOVE and $DELETE, delete(key), the query and scan chains, the Condition chain, Model.transaction.*, batchGet, batchPut and batchDelete.
  • Result fields. Query results carry lastKey, count and timesQueried; scan results carry scannedCount and timesScanned.

Sharing a table during a rollout

Old dynamoose code and new dynotable-orm code can share a table. The main exception is an update of a nested path. For update(key, {$SET: {'address.city': 'Paris'}}), dynamoose 4.2.0 sends SET #a0 = :v0 with #a0 = address.city, so it writes a top-level attribute with that literal name. dynotable-orm sends SET #a0.#a1 = :v0 and writes the field city inside address. A nested $REMOVE from dynamoose 4.2.0 names the same literal attribute, so it removes nothing.

While both run on one table:

  • a nested $SET from the old code leaves the nested field unchanged, and adds a top-level address.city attribute;
  • dynotable-orm does not read that attribute: a Zod schema drops it on read, with no error.

Items written whole, and updates of top-level fields, need no migration. A table whose Dates are not in milliseconds needs a setting (see the Date storage trap). Before a rollout, find the nested update paths in the dynamoose code. Move them first, or check the table for attributes with a dot in their name.

The Date storage trap

dynamoose and dynotable-orm both store a Date as a number of epoch milliseconds by default, timestamps included. A table that dynamoose wrote with its defaults needs no setting.

A table that holds another form does: attributes that the dynamoose schema stores in seconds or as ISO strings, or ISO strings that your own code wrote. Without a setting, a model writes milliseconds next to the old values, and nothing throws: an old ISO item reads back with a string where the type says Date. Set the storage before the first write:

import {model} from 'dynotable-orm';
import {z} from 'zod';

export const Payment = model(
  'Payment',
  z.object({id: z.string(), paidAt: z.date(), dueAt: z.date().optional()}),
  {
    hashKey: 'id',
    defaultDateStorage: 'iso', // the table holds ISO strings
    dates: {dueAt: 'seconds'} // an attribute that dynamoose stored in seconds
  }
);

The codemod reads each attribute's storage from the dynamoose schema, and adds a dates entry only for one in seconds or ISO. ISO strings that your own code wrote are not in the schema: set defaultDateStorage: 'iso' yourself on each such model. The Date storage trap in the migration guide has the rounding rules and the timestamp details.

The deliberate differences

Some dynamoose features are gone on purpose. The migration guide lists each one with its replacement:

  • Items are plain objects. There is no item.save(): write M.create(item, {overwrite: true, keepCreatedAt: true}) for an item you read. item.toJSON() becomes toJSON(item).
  • No callbacks, populate or serializers. Every method returns a Promise.
  • No Table object, and no table management. A model names its own table, so a dynamoose Table that holds several models has no direct equivalent. The library never creates or changes a table; M.createTableInput() gives the CreateTable input.
  • A model must name its hash key. dynamoose takes the first attribute by default.
  • Stricter checks. Every whole-item write runs the full schema, refinements included. An update with nothing to write, an undefined condition value or an empty in() throws an ExpressionError before anything is sent. See stricter checks.
  • queriedCount is DynamoDB's ScannedCount. dynamoose's is always undefined.

The codemod

npm install --save-dev dynotable-orm-codemod
npx dynotable-orm-codemod tsconfig.json --dry-run   # lists the files it would change
npx dynotable-orm-codemod tsconfig.json

It uses the type checker, so run it while the project still resolves dynamoose. It converts schemas to Zod, moves the dynamoose names to named imports, rewrites item.save() where the model is certain, and leaves a TODO(dynotable-orm-codemod) comment above anything it cannot move. The exit code is 0 when nothing is left to do and 1 when a TODO is left. The codemod page has every option.

Performance against dynamoose 4.2.0

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. A query of 100 items is 288 times faster than dynamoose.

These numbers are CPU only, with no network time. In a real call, DynamoDB's latency is usually larger than any of them. The benchmarks give the method and every number.

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.

A nested update has no dynamoose number. dynamoose 4.2.0 writes a path such as notification.status as one top-level attribute with that literal name, not as a change to the nested field, so it does different work and is not timed.

On the 5-attribute schema, all five contenders ran in one process:

Bar chart, calls per second on a 5-attribute schema, higher is better. Create: dynotable-orm 625,000, hand-written AWS SDK 719,000, dynamoose 5,240, ElectroDB 29,100, dynamodb-toolbox 87,000. Get: dynotable-orm 1,490,000, hand-written AWS SDK 1,070,000, dynamoose 3,940, ElectroDB 46,300, dynamodb-toolbox 110,000. Update: dynotable-orm 671,000, hand-written AWS SDK 1,030,000, dynamoose 4,200, ElectroDB 28,800, dynamodb-toolbox 66,200. Query of 100 items: dynotable-orm 35,300, hand-written AWS SDK 37,700, dynamoose 159, ElectroDB 5,780, dynamodb-toolbox 2,170.
  • dynotable-orm is 119 to 379 times faster than dynamoose.
  • ElectroDB 3.9.3 and dynamodb-toolbox 2.11.1 are slower than dynotable-orm on every operation.
  • Hand-written SDK code is faster than dynotable-orm on create, update and query. dynotable-orm is faster on get.

These are CPU numbers for one small schema. They say nothing about features, other schemas or network time.

How it compares

As of 2026-11-06, from each project's docs and npm page:

dynotable-ormdynamoose 4.2.0ElectroDB 3.9.3DynamoDB-Toolbox 2.11.1
Item types from the schemayesno: the item type is a type parameter you writeyesyes
SchemaZod 4, or any Standard Schemadynamoose.SchemaElectroDB entitiesits own schemas, which import from and export to Zod and JSON Schema
Models or entities per tableone model per tablea Table takes several modelsmany entities, with collectionsmany entities, tagged by an entity attribute
LicenseMITUnlicenseISCMIT

For single-table design with many entity types, ElectroDB or DynamoDB-Toolbox is the better fit. Should you use an ORM with DynamoDB? compares all four options with the raw SDK.

Next steps

  1. Read the Date storage trap for every model that reads an existing table.
  2. Run the codemod with --dry-run, then without it, and fix each TODO.
  3. Work through the migration guide, and run your type check and tests.

Updated