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
| storage | stored as | example | sorts by time | DynamoDB TTL |
|---|---|---|---|---|
'milliseconds' | a number (N), epoch ms | 1793957400000 | yes | no |
'seconds' | a number (N), epoch seconds | 1793957400 | yes | yes |
'iso' | a string (S) | '2026-11-06T09:30:00.000Z' | yes | no |
'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
expiresAtattribute 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 DatedefaultDateStoragesets every Date of the model that has no setting of its own..storage(), or thestorage()function, sets one field. It also works inside a list, a record value or a union option, such as eachatineventsabove.datessets a path by its dot path, through objects. A path that both a mark anddatesset must agree, ormodel()throws aConfigError.
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});createdAtis set on a whole-item write of a new item:create,batchPutandtransaction.create. It replaces a value the input carries, unlesskeepCreatedAt: true.updatedAtis set on every write, updates included. So on a timestamped model,update(key, {})writes onlyupdatedAt. AnupdatedAtin the changes is ignored: an item you read and write back withupdate(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'sUpdateItemdoes for a missing key, the item has nocreatedAt. - 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 noDate(no number, for a number field) throws aConfigErroratmodel(). 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
defaultDateStorageunless its ownstoragesays otherwise. - A timestamp whose field is a number, not a Date, holds epoch milliseconds and reads back as a number. A
storagesetting on it throws aConfigError: it is always milliseconds. - Leave out
createdAtorupdatedAtfrom the object to keep only the other one.
Next steps
- The Zod adapter: the
storage()mark. - The
DateStorageandTimestampsreference. - To compare the stored forms of old and new items, open the table in DynoTable, a desktop DynamoDB GUI.