nnestjs-drizzle-crud
Guides

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)

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, 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