Handle Dates and Time Zones in Node.js and PostgreSQL
Model instants, local schedules, Nepal Time, date-only values, and API timestamps without server-time-zone surprises.
On this page
A booking at “09:00” is incomplete until the application knows the date and location time zone. An instant such as 2026-08-12T03:15:00Z identifies one point on the global timeline; 2026-08-12 09:00 is only a wall-clock reading and can map differently depending on where it occurs.
The central design choice is whether a field represents an instant, a local calendar value, or a recurring civil rule. Storing every value as UTC does not answer that modeling question by itself.
UTC, local time, and Nepal Time
UTC is the common reference used by ISO timestamps and distributed systems. “Local time” means a wall-clock representation in a particular region or fixed offset.
Nepal's Trade and Export Promotion Centre lists the country's time as GMT/UTC plus 5 hours 45 minutes. The IANA identifier is Asia/Kathmandu. The 45-minute component is why code that assumes every offset is a whole number of hours fails.
Use Asia/Kathmandu, not an application rule that always adds 5.75 hours. Named IANA zones let the runtime apply time-zone data and make the intent explicit. Nepal currently uses a fixed UTC+05:45 offset, while many international zones change offset with daylight-saving rules and governments can change time policy.
What a JavaScript Date represents
A JavaScript Date holds a timestamp measured in milliseconds from the Unix epoch. It does not retain the original IANA zone or input formatting. Local getters and string formatting use the runtime's current zone unless a zone is specified through Intl.
Prefer an ISO 8601 timestamp containing Z or an explicit numeric offset at API boundaries:
const instant = new Date("2026-08-12T09:00:00+05:45");
console.log(instant.toISOString());
// 2026-08-12T03:15:00.000ZBoth strings identify the same instant. toISOString() serializes it in UTC.
Avoid ambiguous inputs:
new Date("08/12/2026 09:00"); // locale order and zone are unclear
new Date("2026-08-12T09:00:00"); // no offset; interpreted as local timeValidate the accepted wire format instead of relying on permissive parsing. Reject nonexistent calendar dates and require either an offset-bearing instant or a separate local date/time plus an IANA zone.
Format for a user's zone
Keep the stored instant unchanged and choose a zone only for display:
const formatter = new Intl.DateTimeFormat("en-NP", {
timeZone: "Asia/Kathmandu",
dateStyle: "medium",
timeStyle: "short",
hourCycle: "h12",
});
const label = formatter.format(
new Date("2026-08-12T03:15:00.000Z"),
);Node's Intl behavior depends on the ICU data included in the runtime, as documented in Node.js internationalization support. Pin a supported runtime and verify required locales and zones in the deployment image.
Store the user's or organization's IANA time-zone preference, such as Asia/Kathmandu or America/New_York, rather than inferring it permanently from one browser request. A numeric offset alone cannot represent future daylight-saving transitions.
PostgreSQL timestamp and timestamptz
PostgreSQL distinguishes the two timestamp types:
| Type | Meaning | Suitable example |
|---|---|---|
timestamp without time zone | Local date and clock fields with no zone conversion | A store opens at local 09:00 on a specified date |
timestamp with time zone / timestamptz | An absolute instant | Booking start, payment time, audit event |
PostgreSQL's date/time type documentation states that plain timestamp means timestamp without time zone. If an input offset is supplied to a timestamp without time zone value, PostgreSQL ignores it. timestamptz converts the input to an internal UTC instant and renders it in the current database session time zone; it does not preserve the original zone label.
CREATE TABLE booking (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
starts_at timestamptz NOT NULL,
ends_at timestamptz NOT NULL,
display_time_zone text NOT NULL,
CHECK (ends_at > starts_at)
);starts_at and ends_at support correct instant comparisons. display_time_zone preserves the business context used to show or reschedule the booking. Validate zone identifiers in the application or against the time-zone data available to PostgreSQL.
For the database session, an explicit UTC setting makes logs and text query results predictable:
SET TIME ZONE 'UTC';
SELECT starts_at,
starts_at AT TIME ZONE 'Asia/Kathmandu' AS kathmandu_local
FROM booking
WHERE id = $1;The first expression is the instant displayed in the session zone. AT TIME ZONE returns the local wall-clock representation for Kathmandu. Do not build comparisons from formatted text; compare typed timestamps directly.
Instants versus calendar values
Use timestamptz for events that have already happened or are scheduled as a real moment:
- booking start and end;
- created, updated, paid, and logged-in times;
- job execution timestamps;
- token expiration.
Use PostgreSQL date for a calendar day with no time of day:
- date of birth when only the civil date matters;
- invoice due date;
- holiday date;
- anniversary date for one occurrence.
Do not turn a date-only value into midnight UTC merely because the API wants a Date; converting zones can shift it to the previous or next date. Serialize it as YYYY-MM-DD and keep it as a date type throughout the domain.
Recurring birthdays and anniversaries may need month/day fields plus an explicit leap-day policy rather than one timestamp. Reminders such as “09:00 every weekday in the organization's zone” should store the local rule and IANA zone, then calculate each future instant with a time-zone-aware scheduler. If the organization changes zones, define whether existing reminders move with it.
Booking input and output
A booking API can accept an already resolved instant:
{
"startsAt": "2026-08-12T09:00:00+05:45",
"endsAt": "2026-08-12T10:00:00+05:45",
"timeZone": "Asia/Kathmandu"
}The server validates both instants, confirms endsAt > startsAt, and stores them as timestamptz. The zone remains useful for display and later schedule rules. Return a canonical UTC representation:
{
"startsAt": "2026-08-12T03:15:00.000Z",
"endsAt": "2026-08-12T04:15:00.000Z",
"timeZone": "Asia/Kathmandu"
}For a UI that submits local wall-clock fields, use a time-zone-aware library or platform API to resolve the date, clock time, and IANA zone. During a daylight-saving change, a local time can occur twice or not at all. Require a product decision—reject, ask the user to choose an offset, or apply a documented disambiguation rule.
The database non-overlap rule should compare instants using half-open ranges. See Prevent Overlapping Time-Based Bookings in PostgreSQL for the constraint and boundary behavior.
Server time zones should not change results
Development may run in Asia/Kathmandu, CI in UTC, and a production container in another default zone. Code that uses local constructors, getHours(), or toLocaleString() without timeZone can behave differently across them.
Set a predictable process/database zone for operations that rely on defaults, but do not treat configuration as a substitute for explicit modeling. Logs should include ISO UTC timestamps, while user-facing output should pass a deliberate IANA zone.
Test time-zone-sensitive behavior
Run automated tests under more than one process time zone:
TZ=UTC npm test
TZ=Asia/Kathmandu npm test
TZ=America/New_York npm testCover:
- Nepal's
+05:45offset and UTC date-boundary crossings; - zones with daylight-saving gaps and repeated local times;
- date-only round trips without a one-day shift;
- ISO strings with
Z, positive offsets, and negative offsets; - invalid and offset-free input rejection;
- PostgreSQL session zones other than the application process zone;
- adjacent and overlapping bookings expressed with different offsets.
Use a fixed clock in tests rather than changing the host clock. Pin database/runtime versions in CI because time-zone databases can be updated.
Verification checklist
- Every temporal field is classified as instant, local date/time, date-only, or recurrence rule.
- APIs require unambiguous ISO timestamps or local fields plus an IANA zone.
- Booking instants use
timestamptz; date-only values usedate. - The original IANA zone is stored when future display or scheduling depends on it.
- Formatting specifies a user/organization zone instead of adding fixed hours.
- Database comparisons use timestamp values, not formatted strings.
- Tests run in multiple server zones and cover DST gaps, repeats, and Nepal's 45-minute offset.
References
Documentation checked on 2026-08-12:
Related writing
- PostgreSQL Connection Pooling: Direct Connection vs PoolerCompare direct PostgreSQL connections, application-side pools, and external poolers for persistent Node.js, serverless, Prisma, and migration workloads.
- PostgreSQL SSL Modes Explained for Node.js and PrismaUnderstand PostgreSQL sslmode choices, certificate and hostname verification, and the differences between libpq, node-postgres, Prisma, and hosted providers.
- How to URL-Encode Special Characters in PostgreSQL Connection StringsEncode PostgreSQL usernames and passwords safely without breaking the scheme, host, port, database name, or query parameters.