Almost every team we work with hits the same wall: a booking shows 09:00 in the admin panel, 08:00 in the customer email and 03:00 in the database. Nobody changed the code, but the clock lied anyway. If you are searching for a reliable Node js PostgreSQL timezone setup, this guide gives you the full mental model plus the exact code that keeps timestamps stable across your server, your database and every browser.
The short version: store instants in UTC using timestamptz, run your Node process in UTC, and convert to a named IANA zone only at the very edge of your app (rendering, emails, reports). Everything below explains why, and where teams still get burned even after following that rule.
Why timestamps drift between Node.js, PostgreSQL and the browser
A timestamp passes through at least four layers before a human reads it. Each layer has its own idea of “local time”, and any mismatch produces a silent offset instead of an error. PostgreSQL AT TIME ZONE Operator is a useful companion to this.
Four layers, four chances to get it wrong
| Layer | Who decides the zone | Typical failure |
|---|---|---|
| PostgreSQL server | timezone parameter in postgresql.conf or per session |
Managed databases default to UTC, a self-hosted one may default to the host zone |
| node-postgres (pg driver) | Type parsers plus process.env.TZ |
timestamp and date columns are parsed into the Node process local time |
| Node.js runtime | The TZ env var, or the OS/container zone |
Laptop runs Europe/Paris, container runs UTC, staging runs something else |
| Browser | OS settings of the user | new Date(x).toString() renders in the visitor zone, which is not always what you want |
Notice that only the last layer should care about local time. The other three should be boring and identical: UTC.
The classic bug: timestamp without time zone
This is the single most reported issue in the Node plus Postgres world. When you use a timestamp without time zone column, PostgreSQL stores a bare wall-clock value with no offset attached. node-postgres then has to guess, and it guesses using the local time of the Node process. github.com makes the same point with more data.
-- column type: timestamp (no zone)
INSERT INTO events (starts_at) VALUES ('2026-11-01 09:00:00');
const { rows } = await pool.query('SELECT starts_at FROM events LIMIT 1');
console.log(rows[0].starts_at.toISOString());
// TZ=UTC -> 2026-11-01T09:00:00.000Z
// TZ=Europe/Paris -> 2026-11-01T08:00:00.000Z
// TZ=America/New_York -> 2026-11-01T13:00:00.000Z
Same row, same query, three different instants. Nothing is broken, nothing throws, and your scheduler quietly fires an hour early in production. The fix is not to “set the right TZ everywhere”, it is to stop storing ambiguous values.

The rule: store UTC instants in timestamptz
Despite the confusing name, timestamptz does not store a time zone. It stores an absolute point in time (a UTC instant). On input, PostgreSQL converts the value using the offset you provide or the session timezone; on output, it renders it in the session timezone. The stored value itself is unambiguous, which is exactly what you want.
Choosing the right column type
| PostgreSQL type | What it means | What pg returns | Use it for |
|---|---|---|---|
| timestamptz | An absolute instant, normalised to UTC | A correct JS Date |
Almost everything: created_at, logs, bookings, job runs |
| timestamp | Wall clock with no zone, no offset | A Date interpreted in process.env.TZ |
Rare: templates of local time detached from any date |
| date | A calendar day | A Date at local midnight (off-by-one factory) |
Birthdays, invoice periods, contract dates |
| time | A time of day, no date | A string | Opening hours, recurring local start times |
| timetz | Time plus fixed offset | A string | Avoid, it cannot handle DST |
Rule of thumb: if the value refers to a moment that happened or will happen, use timestamptz. If it refers to a human concept like “every Monday at 09:30”, store a time plus an IANA zone name, not an instant. More on that in the recurring events section.
Step 1: force your Node.js process to UTC
Set TZ=UTC as an environment variable in every environment: local, CI, staging, production. It removes an entire class of “works on my machine” bugs, and it makes logs comparable across services.
# .env / docker-compose / ECS task definition / Kubernetes manifest
TZ=UTC
If you cannot control the env var, set it as the very first statement of your entry file, before any module reads the date:
// server.js (first line, before other imports run)
process.env.TZ = 'UTC';
Hosting notes
- Docker: official Node images are UTC already, but a bind-mounted
/etc/localtimecan change that. - Serverless (Lambda, Vercel, Cloud Run): UTC by default, do not rely on it silently, declare it.
- Managed Postgres (RDS, Supabase, Neon, Cloud SQL): session timezone is usually UTC. Verify with
SHOW timezone;instead of assuming.

Step 2: configure node-postgres so nothing is guessed
node-postgres maps timestamptz to a JS Date correctly. The problems come from date (OID 1082) and timestamp (OID 1114). Pin them explicitly.
// db.js
import pg from 'pg';
process.env.TZ = 'UTC';
const { Pool, types } = pg;
// DATE -> keep the calendar day as a plain string, no Date object,
// no accidental midnight shift.
types.setTypeParser(1082, (value) => value); // '2026-09-10'
// TIMESTAMP (no zone) -> if legacy columns still exist, read them as UTC
// explicitly instead of letting the process zone decide.
types.setTypeParser(1114, (value) => new Date(value + 'Z'));
// BIGINT -> string by default, keep it that way for ids.
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
options: '-c timezone=UTC',
application_name: 'api'
});
The options: '-c timezone=UTC' line pins the session timezone for every connection in the pool, so a differently configured database server cannot change how values are rendered in raw SQL output.
Sanity check to run once per environment:
SELECT current_setting('TimeZone') AS session_tz, now() AS now_tz, now() AT TIME ZONE 'UTC' AS now_utc;
Step 3: convert only at the edges with date-fns-tz
Inside your app, a timestamp is a Date (a UTC instant). Zones enter the picture in exactly two places: input parsing (a user typed a local wall-clock time) and output formatting (rendering, PDFs, emails, CSV exports).
npm i date-fns date-fns-tz
The three functions you actually need (date-fns-tz v3 naming):
| Function | Direction | Use case |
|---|---|---|
fromZonedTime(wallClock, tz) |
Local wall clock → UTC instant | Saving a form input before writing to timestamptz |
toZonedTime(date, tz) |
UTC instant → shifted Date for display maths | Grouping, calendar grids, day boundaries |
formatInTimeZone(date, tz, pattern) |
UTC instant → string | Rendering, emails, invoices, exports |
import { fromZonedTime, toZonedTime, formatInTimeZone } from 'date-fns-tz';
// User in Paris books '2026-10-26 09:00' local time
const instant = fromZonedTime('2026-10-26 09:00:00', 'Europe/Paris');
instant.toISOString(); // '2026-10-26T08:00:00.000Z' (CET, after the switch)
// Render the same instant for a New York client
formatInTimeZone(instant, 'America/New_York', 'yyyy-MM-dd HH:mm zzz');
// '2026-10-26 04:00 EDT'
Always store the zone you were given
Do not infer the zone at read time. Save the IANA identifier next to the timestamp when the record is created:
CREATE TABLE appointments (
id bigserial PRIMARY KEY,
customer_id bigint NOT NULL,
starts_at timestamptz NOT NULL,
duration_min integer NOT NULL DEFAULT 30,
booking_tz text NOT NULL, -- 'Europe/Paris'
created_at timestamptz NOT NULL DEFAULT now()
);
On the browser side, get the zone with Intl.DateTimeFormat().resolvedOptions().timeZone and send it with the payload. Never send an offset like +02:00 as a stored preference: offsets expire, zone names do not.

Scheduling features: the 09:00 that must stay 09:00
A booking system has two different promises, and you must pick one per feature:
- Fixed instant: “this webinar starts at 2026-11-05T15:00:00Z”, everyone sees their own local equivalent. Store a
timestamptzand you are done. - Fixed local time: “the clinic opens at 09:00 in Lyon, whatever the season”. Store the local time plus the zone, and compute the instant when you need it.
Mixing them is what produces the infamous “my 9am appointment moved to 8am after the clock change” ticket. Here is the safe write path for the second case:
import { fromZonedTime } from 'date-fns-tz';
export async function bookAppointment({ customerId, localDate, localTime, timeZone }) {
// localDate: '2026-11-02', localTime: '09:00'
const startsAt = fromZonedTime(`${localDate}T${localTime}:00`, timeZone);
const { rows } = await pool.query(
`INSERT INTO appointments (customer_id, starts_at, booking_tz)
VALUES ($1, $2, $3)
RETURNING id, starts_at`,
[customerId, startsAt, timeZone]
);
return rows[0];
}
Passing a JS Date as a parameter is safe: node-postgres serialises it with an explicit offset, so PostgreSQL never has to guess. forestadmin.com makes the same point with more data.
Daily reports: “today” is not the same day for everyone
A report that filters on created_at::date = CURRENT_DATE is a bug waiting to be discovered by a customer in Tokyo. Day boundaries must be computed in a chosen zone, then converted to UTC bounds.
Option A: let PostgreSQL do the shifting
SELECT date_trunc('day', created_at AT TIME ZONE 'America/New_York') AS local_day,
count(*) AS orders,
sum(total_cents) / 100.0 AS revenue
FROM orders
WHERE created_at >= (timestamp '2026-09-01 00:00' AT TIME ZONE 'America/New_York')
AND created_at < (timestamp '2026-10-01 00:00' AT TIME ZONE 'America/New_York')
GROUP BY 1
ORDER BY 1;
Two details that matter:
timestamp '...' AT TIME ZONE 'America/New_York'converts a local wall clock into atimestamptz, which is exactly the bound you want.- Use half-open ranges (
>=and<) instead ofBETWEEN.BETWEENincludes the upper bound and will double count the midnight row.
Option B: compute bounds in Node
import { fromZonedTime } from 'date-fns-tz';
import { addDays, format } from 'date-fns';
function dayBounds(localDay, timeZone) {
const start = fromZonedTime(`${localDay}T00:00:00`, timeZone);
const nextDay = format(addDays(new Date(`${localDay}T12:00:00Z`), 1), 'yyyy-MM-dd');
const end = fromZonedTime(`${nextDay}T00:00:00`, timeZone);
return { start, end };
}
Note the T12:00:00Z trick when incrementing a calendar day: starting from noon avoids landing on a non-existent local midnight in zones that shift the clock at 00:00 (for example America/Santiago).
Multi-tenant reporting
If each customer has their own zone, keep it on the tenant row and pass it as a parameter instead of hardcoding it:
SELECT date_trunc('day', o.created_at AT TIME ZONE t.time_zone)::date AS local_day,
count(*) AS orders
FROM orders o
JOIN tenants t ON t.id = o.tenant_id
WHERE t.id = $1
AND o.created_at >= $2 AND o.created_at < $3
GROUP BY 1
ORDER BY 1;
For heavy dashboards, add an index that matches the expression, or materialise a local_day date column at insert time.

Recurring events and DST: where most apps actually break
A recurring event is a rule, not a list of instants. The moment you store “every Monday at 09:00” as a series of UTC timestamps generated once, the next clock change will shift half of them.
CREATE TABLE recurring_jobs (
id bigserial PRIMARY KEY,
name text NOT NULL,
local_time time NOT NULL, -- 09:00
rrule text NOT NULL, -- 'FREQ=WEEKLY;BYDAY=MO'
time_zone text NOT NULL, -- 'Europe/Paris'
next_run_at timestamptz NOT NULL -- materialised, recomputed after each run
);
The pattern:
- Store the rule (local time, recurrence, IANA zone) as the source of truth.
- Materialise only the next occurrence as a
timestamptzso your worker can queryWHERE next_run_at <= now()with an index. - After each execution, recompute the following occurrence from the rule, never by adding 24 hours or 7 days to the previous instant.
import { fromZonedTime, formatInTimeZone } from 'date-fns-tz';
import { addDays } from 'date-fns';
export function nextOccurrence(afterInstant, localTime, timeZone) {
let cursor = afterInstant;
for (let i = 0; i < 8; i++) {
const localDay = formatInTimeZone(cursor, timeZone, 'yyyy-MM-dd');
const candidate = fromZonedTime(`${localDay}T${localTime}`, timeZone);
if (candidate > afterInstant) return candidate;
cursor = addDays(cursor, 1);
}
throw new Error('No occurrence found');
}
Adding 86400000 milliseconds is the bug. Adding one local day and re-resolving the wall clock is the fix.
Non-existent and ambiguous local times
Twice a year, a local wall clock either does not exist or exists twice. Your validation layer must have an opinion.
| Zone | Next transition | What happens locally |
|---|---|---|
| Australia/Sydney | Sun 4 October 2026, 02:00 | 02:00 to 03:00 never happens |
| Europe/Paris | Sun 25 October 2026, 03:00 | 02:00 to 03:00 happens twice |
| America/New_York | Sun 1 November 2026, 02:00 | 01:00 to 02:00 happens twice |
| Europe/Paris | Sun 28 March 2027, 02:00 | 02:00 to 03:00 never happens |
Practical policies that work well in production:
- Skipped time (spring forward): push the job forward to the first valid instant, or skip that occurrence for non critical jobs. Detect it by round-tripping: convert the wall clock to UTC, format it back in the zone, and compare strings.
- Repeated time (fall back): pick the first occurrence (the earlier offset), which is what
fromZonedTimedoes, and make sure your job runner is idempotent so a double fire is harmless. - Never schedule critical jobs between 01:00 and 03:00 local time if you can avoid it. It costs nothing and removes the whole category.
function isValidLocalTime(localDateTime, timeZone) {
const instant = fromZonedTime(localDateTime, timeZone);
const roundTrip = formatInTimeZone(instant, timeZone, "yyyy-MM-dd'T'HH:mm:ss");
return roundTrip === localDateTime; // false => the local time does not exist
}
ORM and query builder notes
| Tool | Default behaviour | What to do |
|---|---|---|
| Prisma | DateTime maps to timestamp(3) without zone |
Annotate with @db.Timestamptz(3) in your schema |
| Drizzle ORM | timestamp() has no zone unless asked |
Use timestamp({ withTimezone: true, mode: 'date' }) |
| TypeORM | Depends on the declared column type | Declare type: 'timestamptz' explicitly |
| Sequelize | DATE maps to timestamptz on Postgres |
Keep the connection timezone option at +00:00 |
| Knex | table.timestamp() is timestamptz by default on pg |
Avoid useTz: false unless you know why |

Testing your timezone logic
Timezone bugs are trivially testable, which is why shipping them is frustrating. Run your suite in at least three zones in CI:
# package.json scripts
"test": "vitest run",
"test:tz": "TZ=UTC npm test && TZ=America/New_York npm test && TZ=Pacific/Chatham npm test"
Pacific/Chatham is the best stress test available: a 12:45 offset breaks any code that assumes whole-hour offsets. Add these cases to your fixtures:
- An instant during a spring-forward gap and during a fall-back overlap.
- A date exactly at local midnight and at 23:59:59.
- A southern hemisphere zone, where DST runs across the new year.
- A zone with no DST at all (Asia/Kolkata, UTC+05:30) to catch half-hour assumptions.
Also keep your runtime patched: IANA publishes tzdata updates several times a year, and Node ships them with new releases. If you pin an old Node image for years, a country changing its DST policy will silently break your scheduler.
Quick checklist
- Every column that represents a moment is timestamptz.
- TZ=UTC is set in local, CI, staging and production.
- Session timezone is pinned to UTC in the pool config.
- Type parsers for OID 1082 (
date) and 1114 (timestamp) are explicit. - APIs exchange ISO 8601 with Z, never “2026-09-10 08:25” without an offset.
- The user IANA zone is stored on the record or the profile, never guessed at read time.
- Recurring rules store
timeplus zone, and only the next occurrence is materialised. - Reports use half-open UTC ranges derived from local day boundaries.
- Tests run under several zones, including one with a 45 minute offset.
FAQ
Should I use timestamptz or timestamp in PostgreSQL with Node.js?
Use timestamptz for anything that represents a real moment. It stores an unambiguous UTC instant and node-postgres converts it to a correct JS Date regardless of the process zone. timestamp without zone forces the driver to interpret the value in process.env.TZ, which is how the same row ends up with three different values in three environments.
Does timestamptz store the time zone of the user?
No. It stores an instant normalised to UTC and discards the input offset. If you need to know where the event was booked, add a separate text column holding the IANA zone name such as Europe/Paris.
Why does node-postgres return UTC strings when I call toString()?
Because pg maps timestamptz to a JS Date, and a Date is an instant with no zone attached. It renders in the process local time, which is UTC when TZ=UTC. That is correct behaviour. Format for display with formatInTimeZone instead of relying on toString().
How do I stop DATE columns from shifting by one day?
Override the type parser for OID 1082 so that date columns come back as plain strings ('2026-09-10'). A calendar day is not an instant, and converting it to a Date at local midnight is what produces the off-by-one in negative-offset zones.
Can I just set the PostgreSQL server timezone to my country instead?
You can, but it only hides the problem. The session timezone changes how values are rendered, not how they are stored, and it does nothing for the Node process or the browser. UTC everywhere plus conversion at the edge is far easier to reason about, especially once you have more than one region of users.
Is date-fns-tz still the right choice, or should I use Temporal?
The Temporal API is finally landing in browsers and runtimes, and it will eventually be the cleanest way to model zoned date-times. Until it is available everywhere without a polyfill, date-fns-tz (or Luxon if you prefer an object oriented API) remains the pragmatic choice for production apps in 2026. The architecture described here does not change either way: UTC in the database, zones at the edge.
How should my REST API send timestamps?
Always ISO 8601 in UTC with the Z suffix, for example 2026-09-10T06:25:57.218Z. If the client needs a preferred rendering zone, send it as a separate field. Never send a formatted local string as your machine-readable value.
Need help auditing your date handling?
Timezone bugs are cheap to prevent and expensive to discover in production, usually through a customer complaint about a missed appointment or a report that does not match the invoice. At Box Software, we build and maintain Node.js and PostgreSQL applications where scheduling, reporting and recurring jobs have to be right the first time. Get in touch if you want a second pair of eyes on your data model before the next clock change.
