DynamoDB schema validation with dynotable-orm

How do you validate DynamoDB items against a schema?

DynamoDB enforces only the key schema of a table and its indexes, so the item schema lives in your code. dynotable-orm validates every whole-item write against your Zod or Standard Schema before it sends the request, and with the Zod adapter it also checks each updated path. A failed check throws a ValidationError, and nothing is written.

This page lists what runs on each call, what the unknownKeys option does, and what changes when the schema is not Zod. To mark keys, indexes and Date storage on a Zod schema, see the Zod adapter.

What DynamoDB checks, and what it does not

A DynamoDB table has no item schema. CreateTable declares only the key attributes and their scalar types (S, N or B), for the table and for each index. Every other attribute can hold any data type, and two items in one table can hold different attributes under the same name. A typo in an attribute name writes a new attribute.

So a schema in the application is the only place that says what an item holds. dynotable-orm uses it for three jobs: the TypeScript types, the runtime check before a write, and the conversion of values such as Date and Set to DynamoDB's attribute values.

What is checked on each call

callwhat runs
create, batchPut, transaction.createthe whole schema, refinements included, after the defaults are filled in
update, transaction.update (Zod schema)each $SET value and each element $ADD appends to a list, against its field's schema
update (another Standard Schema)nothing: Standard Schema can only validate a whole value
get, query, scan, batchGetnothing: a stored item is read as stored, with undeclared keys dropped and Dates converted back
keys and condition values (Zod schema)the field's transforms run; a value that fails the field's checks is sent as given

A whole-item write is checked before anything is sent. batchPut checks every item first, so one bad item rejects the call and no item is written. The check may be async: a Zod .refine(async …) works on a whole-item write.

A whole-item write

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

extendZod(z);

const Booking = model(
  'Booking',
  z
    .object({
      id: z.string().hashKey(),
      guest: z.email(),
      nights: z.number().int().min(1),
      from: z.date(),
      to: z.date()
    })
    .refine((b) => b.from < b.to, {message: 'from must be before to', path: ['to']})
);

try {
  await Booking.create({
    id: 'b1',
    guest: 'ada@example.com',
    nights: 2,
    from: new Date('2026-12-03'),
    to: new Date('2026-12-01')
  });
} catch (e) {
  if (!(e instanceof ValidationError)) throw e;
  console.log(e.message); // 'from must be before to at to'
  console.log(e.path); // ['to']
  console.log(e.issues.length); // 1: every issue the schema reported
}
  • e.issues holds every issue the schema reported, in Standard Schema form (message, path).
  • e.path is the path of the first issue, and e.message is its message plus at <path>.
  • The defaults are filled in before the check, so a .default() value goes through the field's own checks and transforms too.

Every whole-item write runs the full schema, with its refinements and formats such as z.email() or z.uuid(). dynamoose skipped or warned in some of these places, so old items that it stored can fail when you write them again. Stricter checks in the migration guide lists each case.

Updates are checked path by path

An update does not hold the whole item, so the item-level refinement above cannot run on it. With a Zod schema, each value is checked against the schema of its own field instead:

import {model, ValidationError} from 'dynotable-orm';
import {hashKey} from 'dynotable-orm/zod';
import {z} from 'zod';

const Product = model(
  'Product',
  z.object({
    sku: hashKey(z.string()),
    name: z.string().min(1),
    price: z.number().nonnegative(),
    stock: z.number().int().default(0),
    lines: z.array(z.object({lang: z.string().length(2), text: z.string()})).optional()
  })
);

const failures: unknown[] = [
  // $SET: the value is checked against `price`'s schema.
  await Product.update({sku: 'p1'}, {price: -1}).catch((e) => e),
  // $ADD to a list: each appended element is checked against the element schema.
  await Product.update({sku: 'p1'}, {$ADD: {lines: [{lang: 'eng', text: 'Hi'}]}}).catch((e) => e),
  // $REMOVE of a required field fails: the item would no longer match the schema.
  await Product.update({sku: 'p1'}, {$REMOVE: ['name']}).catch((e) => e)
];
for (const e of failures) if (e instanceof ValidationError) console.log(e.message);

// A number $ADD is a delta, not a value, so `nonnegative()` does not judge it.
await Product.update({sku: 'p1'}, {$ADD: {stock: -5}});
  • A $SET value is also transformed by its field's schema before it is stored.
  • A number or Set operand of $ADD or $DELETE is a delta, so it is not checked. To keep a counter from going below zero, add a condition to the update.
  • A field whose parent has its own checks or transform cannot be updated alone. For example, with period: z.object({from, to}).refine(…), an update of period.from throws an ExpressionError: write the whole period, which the refinement then judges.
  • A refinement on the whole item never runs on an update. When a rule spans several fields, write the item with create(item, {overwrite: true}), or guard the update with a condition.

Read, write and update covers the update operators themselves.

Unknown keys

A key that the schema does not declare is dropped on write and on read, at every depth, except where the schema opens an object. The unknownKeys model option chooses which schemas open one.

'zod' (the default) follows Zod's own meaning:

  • z.object() drops unknown keys.
  • z.looseObject(), or an object with .catchall(), keeps them.
  • z.record() keeps its own entries.
  • z.strictObject() rejects them with a ValidationError (Unrecognized key: "x").

'dynamoose' follows the rule of a converter from Zod to dynamoose that writes saveUnknown: a z.record(), z.any() or z.custom() field opens its parent object at every depth below it, and a z.looseObject() or .catchall() is closed. Use it only when you move schemas that already go through such a converter.

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

const shape = {
  id: z.string(),
  profile: z.object({name: z.string()}),
  flags: z.looseObject({beta: z.boolean()}),
  settings: z.object({theme: z.string(), extra: z.record(z.string(), z.string()).optional()})
};
const input = {
  id: 'u1',
  profile: {name: 'Ada', nickname: 'ada'},
  flags: {beta: true, darkMode: true},
  settings: {theme: 'light', locale: 'en'}
};

const ZodMode = model('User', z.object(shape), {hashKey: 'id'});
const DynamooseMode = model('User', z.object(shape), {hashKey: 'id', unknownKeys: 'dynamoose'});

const a = await ZodMode.create(input, {return: 'request'});
// profile: {name}, flags: {beta, darkMode}, settings: {theme}

const b = await DynamooseMode.create(input, {return: 'request'});
// profile: {name}, flags: {beta}, settings: {theme, locale}: the record opens `settings`
console.log(a.Item, b.Item);

In both modes, a top-level $SET of an attribute the schema does not declare is dropped. An update that is left with nothing to write throws an ExpressionError. To see an item as it is stored, undeclared keys included, read it with get(key, {raw: true}). The migration guide has the rules for $SET values and appended elements in 'dynamoose' mode.

Input types and output types

A write takes the schema's input type, and a read returns its output type. They differ wherever the schema fills in or changes a value:

  • A field with .default() is optional on create and always present on the item a read returns.
  • A field with .transform() takes the input type on a write. The stored value is the transform's output, and nothing transforms it back on read.
  • z.coerce.number() takes any input on a write and reads back as a number.

ItemOf<typeof Model> is the output type, the item a read returns. create, batchPut, transaction.create and the values of update take the input type.

A whole-item write takes a Partial of the input type. So a missing required field compiles, and the runtime check rejects it with a ValidationError. A wrong field name or a value of the wrong type does not compile.

Standard Schema without Zod

Any library that implements Standard Schema can validate a model, such as Valibot or ArkType. The library reads only the schema's validate function, so some features need Zod:

  • Whole-item writes are validated, async validation included. That is the only check.
  • Updates are not checked. Standard Schema can only validate a whole value, not one path of it.
  • Keys and condition values are not transformed. They are sent as you give them, with a Date in its stored form.
  • Keys, indexes and Date storage are model options, as there are no field marks.
  • Reads keep every stored attribute. Nothing is dropped, because the library cannot see the declared keys.
  • Dates read back as Date only on the paths in the dates option and on the timestamps. Elsewhere, a Date is stored by defaultDateStorage and reads back as the stored string or number.
  • createTableInput() throws a ConfigError for a key that is not a Date path: it cannot tell a string key from a number key without the Zod adapter. Write the CreateTable input yourself.
import {model, type StandardSchema} from 'dynotable-orm';

interface EventItem {
  id: string;
  name: string;
  at: Date;
}

// A hand-written Standard Schema. A schema library gives you this object.
const eventSchema: StandardSchema<EventItem> = {
  '~standard': {
    version: 1,
    vendor: 'example',
    validate(value) {
      const v = value as Partial<EventItem>;
      if (typeof v.id !== 'string') return {issues: [{message: 'id is required', path: ['id']}]};
      if (!v.name) return {issues: [{message: 'name is required', path: ['name']}]};
      if (!(v.at instanceof Date)) return {issues: [{message: 'at must be a Date', path: ['at']}]};
      return {value: v as EventItem};
    }
  }
};

export const Event = model('Event', eventSchema, {
  hashKey: 'id',
  dates: {at: 'milliseconds'} // without it, `at` reads back as the stored number
});

await Event.create({id: 'e1', name: 'Launch', at: new Date('2026-12-01T09:00:00Z')});
const event = await Event.get('e1');
console.log(event?.at instanceof Date); // true

Next steps

Updated