Timestamps
Package-managed vs schema and DB-managed created_at and updated_at, and why DB-managed is recommended.
The package can automatically stamp created_at and updated_at on every write, or you can let your database manage them. The two approaches are mutually exclusive. Pick one per project.
Package-managed (convenient)
DrizzleCrudModule.forRoot({
dialect: 'postgresql',
connectionString: process.env.DATABASE_URL,
schema,
defaults: { timestamps: true }, // default
});The package uses the application clock and stamps columns only on writes that go through the package:
create()sets bothcreated_atandupdated_at.update(),softDelete()andrestore()bumpupdated_at.update()with nothing to change is a 400. It does not bumpupdated_aton its own.
The settings name the keys in your Drizzle table object, created_at / updated_at by default. A camelCase schema (updatedAt: timestamp('updated_at')) needs them set per entity:
config: { timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' } }Why does my service fail at startup with a timestamps error?
Because a configured key is not on the table. Before 3.3.1 the package wrote updated_at into the values, Drizzle ignored the unknown key, and updatedAt silently never moved. The service now checks both keys when it is built and stops with a message that names the key to use. Set the keys as above, or turn timestamps off for that entity with timestamps: undefined (or project wide with defaults: { timestamps: false }).
Application clock, partial coverage
Package-managed timestamps use the application server's clock, not the database's. They also only apply to writes through the package. Raw SQL, migrations, and other code paths won't set them. For authoritative, wall-clock-accurate, all-write-path timestamps, use schema/DB-managed.
Schema/DB-managed (recommended)
For authoritative timestamps (database time, on every write path), define them in your Drizzle schema and leave the package's timestamps disabled:
import { pgTable, serial, varchar, timestamp } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 100 }).notNull(),
email: varchar('email', { length: 255 }).notNull().unique(),
createdAt: timestamp('created_at', { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdate(() => new Date()),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
});defaultNow()lets the database setcreated_aton insert.$onUpdate(() => new Date())makes Drizzle bumpupdated_aton every update, including the package's ownupdate().
For updated_at that's authoritative even for raw SQL (e.g. a script that runs UPDATE users SET name = ... directly), add a Postgres trigger or a MySQL ON UPDATE CURRENT_TIMESTAMP clause:
-- Postgres
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER users_set_updated_at
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION set_updated_at();-- MySQL
ALTER TABLE users
MODIFY updated_at TIMESTAMP NOT NULL
DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP;Then turn package timestamps off
DrizzleCrudModule.forRoot({
dialect: 'postgresql',
connectionString: process.env.DATABASE_URL,
schema,
defaults: { timestamps: false },
});The package will not touch the timestamp columns. The database owns them.
Comparison
| Aspect | Package-managed | DB-managed |
|---|---|---|
| Clock source | Application server | Database |
| Coverage | Only package writes | All writes (including raw SQL) |
| Test clock control | Trivial (mock Date.now()) | Requires DB time mocking |
| Setup | defaults: { timestamps: true } | Drizzle defaultNow() / $onUpdate() (or triggers) |
| Source of truth | Less authoritative | More authoritative |
When package-managed is the right choice
- Tests. You can mock the application clock for deterministic timestamps.
- Single-writer apps. Every write goes through the package, so coverage isn't an issue.
- Time zone handling is the app's job. You've already wrapped your DB driver to use UTC.
When DB-managed is the right choice (default recommendation)
- Multiple writers. Other services, scripts, manual SQL.
- Multi-region / replicated databases. You want a single clock source.
- Production. Give the database the last word on time.
Next
Soft delete
Enable soft delete with a deleted_at column, restore rows, list the trash with onlyDeleted or withDeleted, and purge rows for good with forceDelete.
Bulk operations
massCreate, massUpdate, massSoftDelete, massRestore, massDelete and massForceDelete: one statement for the whole set, in a transaction, with a 400 body that lists every failed item.