Timestamps
Package-managed vs schema/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()andsoftDelete()bumpupdated_at.restore()does not touch timestamps.
Column names default to created_at / updated_at. Override per entity:
config: { timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' } }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, 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.