nnestjs-drizzle-crud
Guides

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)

app.module.ts
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 both created_at and updated_at.
  • update() and softDelete() bump updated_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.

For authoritative timestamps (database time, on every write path), define them in your Drizzle schema and leave the package's timestamps disabled:

db/schema.ts
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 set created_at on insert.
  • $onUpdate(() => new Date()) makes Drizzle bump updated_at on every update, including the package's own update().

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

app.module.ts
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

AspectPackage-managedDB-managed
Clock sourceApplication serverDatabase
CoverageOnly package writesAll writes (including raw SQL)
Test clock controlTrivial (mock Date.now())Requires DB time mocking
Setupdefaults: { timestamps: true }Drizzle defaultNow() / $onUpdate() (or triggers)
Source of truthLess authoritativeMore 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

On this page