Drizzle timestamp vs withTimezone in Postgres: the hour that appears when the server is not on UTC
Drizzle timestamp vs withTimezone, tested on a Postgres set to UTC+1: what round-trips, the default that reads an hour ahead, and the migration trap.
The kit's schema has two kinds of timestamp. Better Auth's tables use Drizzle's plain timestamp, without time zone. The kit's own tables use timestamp(..., { withTimezone: true }). On a database whose TimeZone is UTC you will never see the difference between Drizzle timestamp and withTimezone. I pointed the kit at a Postgres whose server time zone was Europe/Stockholm, which is what a local install picks up from the operating system. One row read back an hour in the future, and plain SQL thought a session was an hour older than it was. Here is what each column type does, what I measured, and the migration step that quietly shifts your data if you try to fix it.
Two column types in one schema
// Better Auth table (owned by Better Auth; regenerate with its CLI, do not hand-edit)
createdAt: timestamp("createdAt").defaultNow().notNull(),
// Kit table: an absolute instant
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
timestamp with time zone (timestamptz) stores an instant and renders it in the session's TimeZone. Plain timestamp stores wall-clock digits with no zone attached. Postgres never records which zone those digits meant.
How Drizzle carries a Date through each
Drizzle 0.45.2's postgres.js driver switches off postgres.js's own date parsing: it installs pass-through parsers for type OIDs 1114 (timestamp) and 1184 (timestamptz). Then the column class does the mapping itself:
mapFromDriverValue(value) {
if (typeof value === "string") return new Date(this.withTimezone ? value : value + "+0000");
return value;
}
mapToDriverValue = (value) => {
return value.toISOString();
};
On write, toISOString() ends in Z. For a plain timestamp column Postgres ignores the zone and keeps the UTC digits. On read, Drizzle appends +0000. So a JavaScript Date goes in and the same Date comes out, whatever the session's TimeZone is. Better Auth always supplies its dates from JavaScript: its core table definitions use defaultValue: () => new Date() for createdAt and updatedAt. Its rows round-trip correctly.
What I measured on a UTC+1 database
I ran initdb with TZ=Europe/Stockholm in the environment, and it wrote timezone = 'Europe/Stockholm' into postgresql.conf. Then I applied the kit's migrations and inserted rows through the kit's own db() client on 2026-10-31, when Stockholm is UTC+1:
- Explicit
Dateintouser.createdAt: wrote2026-10-31T15:00:00.000Z, read back exactly that. DEFAULT now()intouser.createdAt, from a Drizzle insert that left the column out: read back 60 minutes ahead of the JavaScript clock.now()is an instant. Storing it in a zone-less column keeps the Stockholm wall-clock digits, and Drizzle then reads those digits as UTC.DEFAULT now()intosubscriptions.created_at(timestamptz): 0 minutes off.- Plain SQL on the explicit row: stored as
2026-10-31 15:00:00, cast totimestamptzas2026-10-31 15:00:00+01. Postgres reads the UTC digits as local time, so every age, comparison or interval againstnow()is off by the session's offset.
That last one is the one that hurts in production. A cleanup job such as DELETE FROM session WHERE "expiresAt" < now(), run in a UTC+1 session, treats a session that expires at 15:00 UTC as expiring at 14:00 UTC, and deletes it an hour early. West of UTC, sessions would linger instead.
So you are exposed when the session's TimeZone is not UTC and either Postgres fills the value itself (a column default, a row typed into a SQL console) or SQL compares or casts it. Plain Drizzle reads and writes of JavaScript dates are fine. Run SHOW timezone on every database the app talks to.
Fixes, cheapest first
Pin the session time zone. postgres.js passes a connection object through as connection parameters. Its README points to the Postgres client-settings docs for what goes in it. In the kit's db():
const sql = postgres(process.env.DATABASE_URL ?? PLACEHOLDER_URL, {
max: 5,
connection: { TimeZone: "UTC" },
});
On the Stockholm database, a connection opened this way reported current_setting('TimeZone') as UTC, and the same age query returned 0 minutes. It only covers the app's own connections. SQL consoles, drizzle-kit and cron scripts open their own. ALTER DATABASE <name> SET timezone TO 'UTC' changes the default for every new session to that database.
Convert the columns to timestamptz, carefully. Change Better Auth's columns to timestamp("createdAt", { withTimezone: true }) and run npm run db:generate. drizzle-kit 0.31.10 emits:
ALTER TABLE "user" ALTER COLUMN "createdAt" SET DATA TYPE timestamp with time zone;
There is no USING clause, and that matters. Without one, Postgres converts the existing digits using the session's TimeZone. On the Stockholm database, a row holding 15:00 UTC came out as 14:00 UTC after that exact statement. With the clause added by hand, it stayed at 15:00:
ALTER TABLE "user" ALTER COLUMN "createdAt"
SET DATA TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
Edit the generated file before applying it. The order of operations is in shipping a Drizzle schema change when the deploy does not run migrations. The tradeoff: the schema comment asks you to regenerate Better Auth's tables with its CLI rather than hand-edit them, so a type change is a hand edit you have to keep across every regeneration.
Things that bite
date_truncfollows the session too.date_trunc('month', created_at)on atimestamptzcolumn cuts months in the session's zone. The kit's monthly quota avoids that by computing the month start in JavaScript, withsetUTCDate(1)andsetUTCHours(0, 0, 0, 0), and comparing against an instant.- Consoles render in their own zone. In the Stockholm session, a
timestamptzvalue printed as2026-10-31 16:04:00.291915+01. That is correct, but easy to misread as a bug. - Migrations run in drizzle-kit's session. A data migration that uses
now()against a zone-less column inherits whatever TimeZone that connection has.
Shipwright, the Next.js and Claude starter kit this blog documents, ships this schema and the postgres.js client it runs on. The pool settings are covered in Postgres connection pooling for serverless Next.js.