A user in Cairo schedules "9am Friday." Your server sits in Frankfurt. Your phone test device is on GMT+2 in November and GMT+3 by June because Egypt changed its mind about daylight saving again. Somewhere in that pipeline a bare 2026-11-13 09:00 changes meaning, and the reminder fires at the wrong hour. Nothing crashed. The column just stored a different thing than the user meant.
The constraint
Two different values get called "a date," and confusing them is the entire bug class. An instant is a point on the timeline: the same moment everywhere on earth, created_at and sent_at and refunded_at. A wall-clock intention is what a human meant: "9am Friday in Cairo," "all day November 3," "every Monday at close of business." An instant survives timezone math. An intention does not even have a timezone answer until you resolve it.
The storage layer has to keep the two apart, display correctly for users in any zone, and still be right after a legislature moves a DST boundary.
The options
Naive local time. timestamp without time zone, a bare string, whatever the server's clock happened to say. Postgres is blunt about what this type does: it "will silently ignore any time zone indication." The Postgres wiki leads its Date/Time don't-do-this entries with it, because it is "a picture of a calendar and a clock rather than a point in time." Move the server, add a replica in another region, let a user travel, and every stored value silently reinterprets. Rejected on ambiguity, not precision.
Instant plus fixed offset. 2026-11-13 09:00+02:00. This records a real instant, so it beats naive storage. But if the column was meant to hold an intention, the offset froze one political fact: Egypt's offset at write time. Egypt suspended DST for years and brought it back in 2023. Anyone who stored the offset now holds values that are wrong half the year, and there is no way to tell which rows.
Instant in UTC. timestamptz, or epoch milliseconds. The Postgres docs state the semantics exactly: "the value is stored internally as UTC, and the originally stated or assumed time zone is not retained," and on output it "is always converted from UTC to the current timezone zone, and displayed as local time in that zone." One value, one meaning, converted at the edges.
Wall-clock fields plus a zone name. Store hour=9, minute=0, day=friday, zone='Africa/Cairo' and resolve to an instant at run time. This is the only option that preserves intent, because the zone name carries what the offset cannot.
The mechanism
The zone-versus-offset distinction is in the same Postgres page: an abbreviation like PST "merely defines a particular offset from UTC," while full time zone names "can imply a set of daylight savings transition-date rules." The IANA database those names point at "is updated periodically to reflect changes made by political bodies to time zone boundaries and daylight saving rules." Africa/Cairo is not +02:00; it is a rule set that ran local mean time at 2:05:09 until 1900, tracked Egypt's DST experiments since, and will track the next amendment. An offset is one row of that table, frozen.
Internally timestamptz is microseconds since the Postgres epoch, 2000-01-01 UTC. The display zone is a session setting: SET TIME ZONE for SQL sessions, PGTZ for libpq clients on connect.
JavaScript's version of the footgun is parse shape. This runs anywhere Node runs:
// "no offset" parses differently depending on the string shape
const dateOnly = new Date("2026-03-08");
const dateTime = new Date("2026-03-08T00:00:00");
console.log(dateOnly.toISOString());
// "2026-03-08T00:00:00.000Z" on every machine
console.log(dateTime.toISOString());
// shifts by the machine's local offset
console.log(dateTime.getTime() - dateOnly.getTime());
// the gap equals your utc offset in ms; only 0 on a utc box
MDN documents the reason in one sentence: date-only forms parse as UTC, date-time forms without an offset parse as local, "due to a historical spec error that was not consistent with ISO 8601 but could not be changed due to web compatibility." Your API cannot tell which shape a client sent, so the contract has to demand offset-bearing strings.
Where it bites
DST makes wall times ambiguous and nonexistent. When clocks fall back, an hour happens twice; when they spring forward, an hour never happens. PEP 431, Python's proposal for fixing exactly this, describes both cases: the repeated window is ambiguous, the skipped window cannot exist. A wall-clock job stored as "02:30 local" fires twice or never, and your scheduler needs a written policy for both before it ships.
The written zone is gone. timestamptz discards the zone a value was written under, per the docs above. If "the user's local time at write time" is a question your product ever asks, the zone is a column you store, not something the timestamp remembers.
Display is session-dependent. The same row renders differently in sessions with different TimeZone settings. A report regenerated in a different session can disagree with itself. Pin UTC at the database, the connection string, the container, and the ORM; convert once, for display, with the user's stored zone name.
Rules drift under your data. The IANA database ships inside your OS and your Postgres build. Storing zone names is correct only as long as the tz files are current; an app pinned to an old tzdata quietly uses last year's DST calendar.
Epoch seconds have a ceiling. A signed 32-bit time_t fails at 03:14:07 UTC on 2038-01-19, which is why the POSIX spec now says the fix is "to make time_t wider rather than to make it unsigned." Milliseconds in a 64-bit int are safe until long after your schema is someone's legacy problem.
The numbers
timestamptzresolves to one microsecond and spans 4713 BC to 294276 AD per the Postgres type table; a JSDateis milliseconds since 1970-01-01 UTC per MDN, andDate.now()can be rounded by privacy settings.- Temporal, the proper replacement for
Date, reached Stage 4 in March 2026 and has shipped in Firefox 139, Chrome 144, and Node 26. The migration is real butDateis what ships today. - The IANA tzdb holds offset history per location going back to local mean time; a single zone identifier can carry over a century of rule changes.
Our position
Every persisted timestamp is an instant, stored in UTC: timestamptz in Postgres, epoch milliseconds in payloads. Everything a human intended stays as wall-clock fields plus an IANA zone name, resolved to instants when it runs. Offsets and naive strings are never stored, and client contracts require offset-bearing input so the shape cannot lie. Convert at exactly one place, the display edge, with the zone the user chose.
The takeaway
store utc: timestamptz / epoch ms
display converts; storage never reinterprets
store fields + iana zone name, resolve at run time
'+02:00' freezes one political fact, do not store
never exists; wall-clock jobs need a policy for both
require offset-bearing strings in every api contract
convert once at the edge with the user's zone
Timezone handling is one of those defaults we set in the data model on day one of a custom software build, because retrofitting it means rewriting every date that ever flowed through the system. If your schema stores local times and you want the migration scoped, send us the schema.
// sources
// next step
We work this way on client projects too.
Tell us what you are making. We reply with a plan, a timeline, and a price.
