Dates and timestamps in DynamoDB with dynotable-orm

How do you store a Date in DynamoDB?

DynamoDB has no date type, so a date is stored as a number or a string. dynotable-orm stores a Date field as a number of epoch milliseconds by default, as dynamoose does, or as epoch seconds or an ISO 8601 string when you set its storage, and reads it back as a Date.

This page covers the three storage modes, where to set them, the createdAt and updatedAt timestamps, and the trap for existing tables that hold another form.

The three storage modes

storagestored asexamplesorts by timeDynamoDB TTL
'milliseconds'a number (N), epoch ms1793957400000yesno
'seconds'a number (N), epoch seconds1793957400yesyes
'iso'a string (S)'2026-11-06T09:30:00.000Z'yesno
  • 'milliseconds' is the default, for Dates and for timestamps, as in dynamoose.
  • 'iso' is opt-in. It is readable in the console, and it sorts by time as a string, because every value has the same UTC format.
  • 'seconds' rounds a Date to the nearest second. A timestamp in seconds is rounded down instead, as dynamoose does.
  • TTL needs seconds. DynamoDB's time to live reads only a number of epoch seconds, so store an expiresAt attribute with 'seconds'.

Pick one storage per attribute and keep it. A key condition or a sort compares a string with a string and a number with a number, so an attribute that holds both breaks every range query on it.

Where to set the storage

Three places, from the widest to the narrowest:

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

export const Session = model(
  'Session',
  z.object({
    id: z.string(),
    startedAt: z.date(), // the model default: ISO
    expiresAt: storage(z.date(), 'seconds'), // a field mark: seconds, for TTL
    audit: z.object({reviewedAt: z.date().optional()}).optional(),
    events: z.array(z.object({at: storage(z.date(), 'milliseconds')})).default([])
  }),
  {
    hashKey: 'id',
    defaultDateStorage: 'iso', // every Date without its own setting
    dates: {'audit.reviewedAt': 'milliseconds'} // a model option, by dot path
  }
);

const session = await Session.create({
  id: 's1',
  startedAt: new Date(),
  expiresAt: new Date(Date.now() + 24 * 3600 * 1000)
});
console.log(session.expiresAt instanceof Date); // true: read back as a Date
  • defaultDateStorage sets every Date of the model that has no setting of its own.
  • .storage(), or the storage() function, sets one field. It also works inside a list, a record value or a union option, such as each at in events above.
  • dates sets a path by its dot path, through objects. A path that both a mark and dates set must agree, or model() throws a ConfigError.

The storage also applies wherever the Date goes: a key, a condition value, a query value and startAt(). where('startedAt').lt(new Date()) sends an ISO string here. A Date field also accepts its stored form in a condition, such as an ISO string.

How a Date reads back

On read, a stored value of the path's type turns back into a Date: a string for 'iso', a number for the other two. A stored value of the other type is returned as stored. On a 'milliseconds' path, an ISO string reads back as a string, though the type says Date, and nothing throws.

That is the trap below. To find such items, read with get(key, {raw: true}), which returns every value as stored.

With a schema that is not Zod, only the paths in dates and the timestamps read back as Date. Every other Date is written by defaultDateStorage and reads back as the stored string or number. See Standard Schema without Zod.

The Date storage trap for existing tables

dynamoose and dynotable-orm both store a Date as a number of 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. Old ISO items read back with a string where the type says Date, old values in seconds read back as Dates in January 1970, and range queries on the attribute stop matching the old items. Nothing throws.

Set the storage before the first write:

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

export const Invoice = model(
  'Invoice',
  z.object({
    id: z.string(),
    issuedAt: z.date(),
    dueAt: z.date().optional(),
    createdAt: z.date().optional(),
    updatedAt: z.date().optional()
  }),
  {
    hashKey: 'id',
    timestamps: true,
    defaultDateStorage: 'iso', // the table holds ISO strings
    dates: {dueAt: 'seconds'} // an attribute declared with storage: 'seconds' in dynamoose
  }
);

The codemod reads each attribute's storage from the dynamoose schema. It adds a dates entry only for an attribute in seconds or ISO, and a timestamp gets its own storage. 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.

Timestamps

timestamps: true keeps a createdAt and an updatedAt attribute on every item:

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

extendZod(z);

const Post = model(
  'Post',
  z.object({
    id: z.string().hashKey(),
    title: z.string(),
    createdAt: z.date().optional(),
    updatedAt: z.date().optional()
  }),
  {timestamps: true}
);

const post = await Post.create({id: 'p1', title: 'Hello'});
console.log(post.createdAt?.getTime() === post.updatedAt?.getTime()); // true: one clock read

const edited = await Post.update({id: 'p1'}, {title: 'Hello, world'});
console.log(edited.updatedAt! > edited.createdAt!); // true, once the clock has moved

// Write back an item you read, keeping its createdAt (dynamoose's item.save()).
await Post.create({...edited, title: 'Hi'}, {overwrite: true, keepCreatedAt: true});
  • createdAt is set on a whole-item write of a new item: create, batchPut and transaction.create. It replaces a value the input carries, unless keepCreatedAt: true.
  • updatedAt is set on every write, updates included. So on a timestamped model, update(key, {}) writes only updatedAt. An updatedAt in the changes is ignored: an item you read and write back with update(item) gets a new stamp. dynamoose writes both values, and DynamoDB rejects the update.
  • An update never sets createdAt. If the update creates the item, as DynamoDB's UpdateItem does for a missing key, the item has no createdAt.
  • Declare both fields in the schema. The library sets their values, so a value from .default() is discarded. With the Zod adapter, a field can be required (createdAt: z.date()), and the item type keeps it required. A required field that takes no Date (no number, for a number field) throws a ConfigError at model(). With another Standard Schema, make them optional: a required one fails the first write.
  • Timestamps are not validated, as in dynamoose.

Each one takes its own name and storage:

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

export const Event = model(
  'Event',
  z.object({id: z.string(), created: z.date().optional(), modified: z.number().optional()}),
  {
    hashKey: 'id',
    timestamps: {
      createdAt: {name: 'created', storage: 'seconds'},
      updatedAt: {name: 'modified'} // a number field: always epoch milliseconds
    }
  }
);
  • A timestamp follows defaultDateStorage unless its own storage says otherwise.
  • A timestamp whose field is a number, not a Date, holds epoch milliseconds and reads back as a number. A storage setting on it throws a ConfigError: it is always milliseconds.
  • Leave out createdAt or updatedAt from the object to keep only the other one.

Next steps

Updated