DEV Community

Cover image for Datetime Is Tech Debt You Didn't Know You Had
Waseem Al-Dmeiri
Waseem Al-Dmeiri

Posted on • Originally published at dmeiri.dev

Datetime Is Tech Debt You Didn't Know You Had

Why This Matters

Almost every developer has a datetime story. An appointment showing up an hour late. A report aggregating the wrong day's data. A notification firing at 3am. It works fine in development, ships, and then a user in a different timezone files a bug you can't immediately reproduce.

A 2025 empirical study of real-world bugs in open-source software found that 53.6% of datetime bugs are timezone-related, and 27.8% stem specifically from mixing naive and timezone-aware datetime objects incorrectly. These are the result of treating datetime as a trivial detail.

Datetime is one of the most common forms of silent tech debt. It's easy to defer because it appears to work until it doesn't. By the time it fails, it's in production for a user in a country you didn't test for. By then, you're stuck refactoring entire columns in a live production database.

This post is the reference I wish I'd had earlier — terminology, mental models, and the decisions that actually matter. Treat it as a cheat sheet.


The Terminology (Get This Right First)

Before anything else, let's align on words. These get conflated constantly.

Term What it means
UTC The universal time reference. Every other timezone is an offset from it. Not a timezone itself — a standard.
Offset A fixed shift from UTC. +04:00 means 4 hours ahead. −05:00 means 5 hours behind.
Timezone (TZ) A named region with rules — including DST. Asia/Dubai is always UTC+4. America/Toronto is UTC−5 in winter, UTC−4 in summer. An offset is a snapshot; a timezone is the full rulebook.
Wall clock time What the clock on the wall shows in a given place. 09:00 in Dubai. Meaningless without knowing where.
Naive datetime A datetime with no timezone or offset attached. 2024-06-01T09:00:00. Could mean anything.
Aware datetime A datetime with offset attached. 2024-06-01T09:00:00+04:00. Unambiguous.
ISO 8601 The international standard for representing datetimes as strings. What you should always use for transfer.
Unix timestamp Seconds (or milliseconds) since Epoch: 1970-01-01T00:00:00Z Always UTC. Useful for math, not for display.
DST Daylight Saving Time. The reason offset ≠ timezone — the same timezone can have different offsets at different times of year.

UTC: The One Ring

UTC is the reference point everything else orbits. It doesn't observe DST. It doesn't change. When you know a moment in UTC, you can derive the correct wall clock time anywhere in the world by applying the local offset.

UTC example

Store in UTC. Display in local. That's the whole principle. Everything else is implementation detail.


ISO 8601: The Transfer Format

When moving a datetime between systems — from backend to frontend, from database to API, from one language to another — you need a string format both sides understand. That format is ISO 8601.

2024-06-01T09:00:00          ← naive (no offset) — avoid this
2024-06-01T09:00:00Z         ← UTC (Z = zero offset)
2024-06-01T09:00:00+00:00    ← UTC (explicit offset)
2024-06-01T13:00:00+04:00    ← Dubai local time
2024-06-01T05:00:00−04:00    ← Toronto local time
Enter fullscreen mode Exit fullscreen mode

The last four all represent the same moment in time. A system that parses ISO 8601 correctly will handle all of them identically — converting to UTC internally and displaying in the user's local timezone.

The naive format (no offset) is the one to avoid. It looks like a datetime but carries no timezone information. Every system that receives it has to guess — and they won't all guess the same thing.


How Postgres Handles This

Postgres has two datetime types that look similar but behave completely differently:

Type What it stores Timezone-aware?
date Calendar date only (2024-06-01) No
time Time of day only (09:00:00) No
timestamp Date + time, verbatim, no conversion No
timestamptz Date + time, converted to UTC internally Yes

timestamp without tz vs timestamptz

Default to timestamptz for every datetime column. The overhead is negligible. timestamp is appropriate only when you can guarantee the writer and reader will always be in the same timezone — a constraint that rarely holds as products grow.


Don't Split Date and Time Into Separate Columns

This one is counterintuitive but important.

A common pattern is storing date and time separately:

appointment_date DATE,   -- "2024-06-01"
appointment_time TIME,   -- "09:00:00"
Enter fullscreen mode Exit fullscreen mode

It seems clean. But it breaks down across timezones.

The problem: a "day" is not the same moment everywhere.

If a client books at 09:00 Dubai time on June 1st, that's 05:00 UTC on June 1st. But for a user in Toronto viewing the same record, it's 01:00 on June 1st — still June 1st, fine. But what if the appointment was at 02:00 Dubai time? That's 22:00 UTC on May 31st. The date changes depending on where you're standing.

When you store date and time as separate fields, you lose the ability to reconstruct the correct moment in time without knowing the business's timezone — information that isn't in those columns.

date time two clolumns vs timestamptz one coloumn

Use a single timestamptz column. It stores the full moment — date, time, and UTC equivalent — atomically. You can always extract the date or time for display on the client.


When date Alone Is Correct

There are cases where a plain date column is genuinely right: when the value is inherently timezone-independent.

Birthdate is the canonical example. If someone was born on June 1st, that's true regardless of what timezone you're in when you ask. It's a calendar fact, not a moment in time. Storing it as timestamptz would actually introduce a problem — you'd need to decide which timezone to use, and converting it could shift the date.

Same for:

  • Public holidays (2024-12-25 is Christmas everywhere)
  • Contract effective dates (2024-07-01 a policy takes effect)
  • Expiry dates on a license or document

The test: "Does the answer change depending on where you are when you ask?" If no — use date. If yes — use timestamptz.


The Cheat Sheet

Timestamptz dates cheatsheat

Write path (always):

  • Send an offset-aware ISO 8601 string: 2024-06-01T13:00:00+04:00
  • Never send a naive string to a timestamptz column — Postgres will assume UTC

Read path (always):

  • For timestamptz: the string comes back with +00:00 — parse it, then convert to the user's local timezone before display
  • For timestamp / date: the string comes back with no offset — treat as-is, no conversion

Never:

  • Store datetime as a plain string
  • Split a moment in time across separate date + time columns
  • Assume the server timezone matches the user timezone
  • Skip .toLocal() (or equivalent) on display

One Last Thing: Offset vs Timezone

These are not the same, and the distinction matters if your product operates across DST boundaries.

+04:00 is an offset — a fixed number. Asia/Dubai is a timezone — a named ruleset that includes DST transitions.

Dubai doesn't observe DST, so Asia/Dubai is always +04:00. But America/Toronto is −05:00 in winter and −04:00 in summer. If you hardcode the offset, you'll be wrong half the year.

Store and reason in named IANA timezones (Asia/Dubai, Europe/Berlin, America/Toronto). Use the offset only at the moment of conversion, derived from the IANA name at that specific point in time.


Datetime isn't hard. But it requires deliberate thought — the kind most developers postpone until users are in two countries and support tickets start rolling in. Get it right from the start and it stays invisible, which is exactly where infrastructure should be.

Top comments (0)