We used dynamoose for years in a TypeScript codebase with many services. Its API, borrowed from Mongoose, is a good one: a model per table, get and create by key, update with $SET and $ADD, and query chains that read like the request they build.

Two things pushed us to replace it. The first was CPU. On a model of about 60 attributes, a dynamoose 4.2.0 create costs about 3.9 ms of CPU in our benchmark, as much as DynamoDB usually takes to answer, or more.

The second was fit. Our models were Zod schemas, and some of our calls went around the ORM to the raw AWS SDK, where they lost the model's types. Removing the CPU cost meant a new core, and our Zod schemas meant a different schema input, so we wrote a new library.

dynotable-orm keeps dynamoose's call shapes. Its whole-item writes store the same bytes as dynamoose 4.2.0, so old and new code can share a table. It is open source under MIT.

The ORM's CPU time matched the database's response time

AWS describes DynamoDB as single-digit-millisecond. Our benchmark measures CPU only, on a stub client, so it has no network time. On a model of about 60 attributes, dynamoose 4.2.0 spends about 3.9 ms of CPU on a create, and about 12.7 ms on an update that returns the updated item.

That cost comes from what dynamoose does at runtime. It converts every item through its schema attribute by attribute, on writes and on reads, and on a read it type-checks every attribute it returns.

That work gives dynamoose a flexible schema language and checks that run without a compiler. It also puts a large cost on each attribute, and our largest models had many.

We wanted the ORM to cost about what hand-written AWS SDK code costs. So dynotable-orm compiles the schema once per model, on its first call, into converters for its Dates, defaults and undeclared keys.

A write runs the schema's own validator, with those converters around it. A read runs only the converters, with no type check. A stored value of the wrong type is returned as stored, so on a read the type states what the schema expects, not what was checked.

Measured on 2026-11-06, CPU only, on a stub client, a create 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.

Every contender validates on write: dynamoose with its own schema, and dynotable-orm and the hand-written code with the same Zod schema. Each number is the median of 20 interleaved rounds.

Reads compare different work, and so does part of an update, which returns the updated item: dynamoose 4.2.0 type-checks each attribute of every item it reads, and dynotable-orm runs no check on read. The tables and the method are on the benchmarks page.

One Zod schema replaced two schemas and a converter

Our models were Zod schemas. Our own converter turned each one into a dynamoose schema, and Zod extensions carried the keys, the indexes and the Date storage across.

Two schemas meant two sources of truth. The TypeScript types came from one, and what was checked and stored came from the other.

dynotable-orm takes the model schema as any Standard Schema, the interface that Zod, Valibot and ArkType implement. A Zod adapter puts the DynamoDB facts on the fields themselves: the partition key, the sort key, the indexes and how a Date is stored.

The types come from the same schema. Keys, update paths up to four levels below a top-level attribute, condition values and the attributes an index projects are all typed from it, so a typo in a path or a wrong value type fails at compile time.

import {model} from 'dynotable-orm'; import {hashKey} from 'dynotable-orm/zod'; import {z} from 'zod'; const User = model( 'User', z.object({ id: hashKey(z.string()), plan: z.enum(['free', 'team']), address: z.object({city: z.string()}) }) ); await User.update('u1', {$SET: {'address.city': 'Valencia'}}); // @ts-expect-error not a path of the item await User.update('u1', {$SET: {'adress.city': 'Valencia'}}); // @ts-expect-error not a plan await User.update('u1', {plan: 'enterprise'}); 

Some calls went around the ORM to the raw SDK

The clearest sign of a poor fit was in our own code. Some calls needed something that the ORM did not offer, so they used the raw AWS SDK instead:

  • an update that sets a value only if the attribute does not exist yet, with if_not_exists;
  • a read of an item whose stored values no longer match the schema;
  • a read of an old item whose key has a different case from the one the key transform produces;
  • a different client, or an abort signal, for one call;
  • the stored item that made the condition of a transaction entry fail.

Each of these calls dropped the model's types, and each one had its own marshalling code. We made all of them part of the API: ifNotExists(), {raw: true}, {rawKey: true}, a per-call client and abortSignal, and the failing item of each canceled transaction entry, read by its model.

import {ifNotExists, model} from 'dynotable-orm'; import {hashKey} from 'dynotable-orm/zod'; import {z} from 'zod'; const Account = model( 'Account', z.object({id: hashKey(z.string()), balance: z.number(), createdAt: z.number().optional()}) ); const request = await Account.update( 'a1', {$SET: {createdAt: ifNotExists(Date.now())}, $ADD: {balance: 10}}, {return: 'request'} ); console.log(request.UpdateExpression); // ADD #a1 :v1 SET #a0 = if_not_exists(#a0, :v0) 

A new library only had to keep two things

Part of the speed comes from dropping the type check on read, which dynamoose users rely on. The schema they write would also change, from dynamoose's own schema language to Zod or another Standard Schema. For them that is a different library, so we made it one, and kept only the two things that our code and our tables depended on.

The first is the call shapes. get, create, update, delete, query and scan, the update operators and the condition chain all work as in dynamoose. Most changes in a migration are imports and schemas, and a codemod makes them.

The second is the stored bytes. Whole-item writes store the same items as dynamoose 4.2.0. Top-level updates do too, apart from three edge cases that the migration guide numbers: an update of a model with a combine attribute that writes none of its sources, a $REMOVE of a set with a $SET of a list in one call, and an update that passes its own updatedAt on a model with timestamps.

An equivalence suite runs in CI on every pull request. It gives both libraries the same inputs on DynamoDB Local, compares what each call returns and the raw stored items, and makes each library read the items that the other wrote. A difference that is not on the known list fails it.

Because the stored items match, old and new code can share a table while services move one at a time.

Every difference is listed, and two matter during a rollout

The migration guide numbers each difference and gives its reason. Some dynamoose features are gone on purpose: item.save(), callbacks, populate and table management. Checks are stricter too: an update with nothing to write throws before anything is sent.

Two differences matter while old and new code share a table. The first is updates of nested paths. dynotable-orm writes a.b inside the map a, and dynamoose 4.2.0 writes it as a top-level attribute named a.b.

dynotable-orm drops such an attribute on read, so a read followed by a write of the whole item removes it. Before you move, look for top-level attributes with a dot in their name. Until every service has moved, make nested-path updates only from dynotable-orm, so move that code first.

The second is Dates. dynotable-orm stores them as epoch milliseconds, as dynamoose does by default. A codebase whose items hold ISO strings sets defaultDateStorage: 'iso' on each model, or new writes put numbers next to the old strings.

The first steps of a move look like this:

npm install dynotable-orm @aws-sdk/client-dynamodb zod npm install --save-dev dynotable-orm-codemod npx dynotable-orm-codemod tsconfig.json --dry-run 

Five checks that transfer to any library swap

  • Measure CPU on a stub client. Compare each call against hand-written SDK code that does the same work, and check that both send the same request before you time them. A slow library looks like a slow database.
  • Count the calls that go around the library. Each one marks a feature the library is missing.
  • Keep the old call shapes. Then most of the migration is mechanical, and a codemod can do it.
  • Keep the stored bytes, and test them. Give both libraries the same inputs, compare the raw stored items, and have each library read what the other wrote. Then nobody migrates data, and the rollout can be gradual.
  • List every difference you keep, with its reason. A migration guide that lists them is worth more than a claim of full compatibility.

dynotable-orm is on GitHub and on npm as dynotable-orm. Getting started sets up a first model. If you use dynamoose now, the dynamoose alternative page shows what stays the same, and the migration guide shows what changes.

To check a table for ISO date strings before a rollout, download DynoTable, our desktop GUI for DynamoDB.