Unix timestamps are unambiguous integers. ISO 8601 is a readable standard with a dozen valid variants. Between them they cover almost all date interchange in software — and the bugs happen at the boundary.
Two formats carry almost all the dates that move between systems: the Unix timestamp, a plain integer, and ISO 8601, a human-readable string. Each is excellent at what it does. The bugs don't live inside either format — they live at the boundary, in the moment you convert one to the other, parse a string, or forget which units you're in. Get the boundary right and dates stop being a source of 3 a.m. surprises.
Unix timestamp: one number, no ambiguity
A Unix timestamp is the number of seconds since the Unix epoch — midnight UTC on 1 January 1970. That's its entire definition, and its great virtue: no timezone, no format, no ambiguity, just a monotonically increasing integer that means the same thing everywhere. Wikipedia states it plainly:
"Unix time is a date and time representation widely used in computing. It measures time by the number of non-leap seconds that have elapsed since 00:00:00 UTC on 1 January 1970, the Unix epoch."
— Wikipedia, "Unix time" (CC BY-SA 4.0)
Because it's just a count from a fixed point in UTC, a Unix timestamp is the safest way to store and compare instants. No two systems can disagree about what it means.
ISO 8601: readable, but not all variants are safe
ISO 8601 is the human-friendly counterpart, and it has several forms of varying safety. 2026-06-06 is a date. 2026-06-06T09:30:00 adds a time but no offset — and that's the dangerous one, because it doesn't say which timezone it's in. The safe forms carry the offset explicitly: 2026-06-06T09:30:00+02:00, or 2026-06-06T09:30:00Z for UTC. The rule for anything crossing a boundary: include the offset or the Z. An offset-less ISO string is a date wearing a disguise — it looks precise and isn't.
The JavaScript parsing trap
This one has bitten nearly every JavaScript developer. new Date("2026-06-06") — a date-only string — is parsed as midnight UTC. But new Date("2026-06-06T00:00:00") — a date-and-time string with no offset — is parsed as midnight local time in V8 (Chrome and Node). Same apparent date, two different instants, depending on whether you included the time portion. It's a documented inconsistency, not a bug you can fix, and the defence is to always parse explicit-offset strings and never rely on the engine guessing.
Milliseconds vs seconds: the factor-of-1000 bug
Unix time is traditionally in seconds. JavaScript's Date.now(), however, returns milliseconds. So the same moment is 1749196800 in seconds and 1749196800000 in milliseconds — a thousand-fold difference. Mix them up and your dates land in 1970 (you treated milliseconds as seconds) or centuries in the future (you treated seconds as milliseconds). It's one of the most common date bugs there is, and it shows up as wildly wrong dates rather than subtly wrong ones — which, small mercy, at least makes it easy to spot once you know the cause.
The Y2K38 problem
A 32-bit signed integer maxes out at 2,147,483,647. As a Unix timestamp counting seconds, that value is reached at 03:14:07 UTC on 19 January 2038 — after which a 32-bit signed time_t overflows and wraps to a negative number, landing back in 1901. Modern 64-bit systems are fine for effectively all practical purposes. But 32-bit time still lurks in embedded devices, some legacy databases, and old C code — so Y2K38 is a real, dated deadline for a specific class of systems, not a hypothetical. If you maintain anything with a 32-bit time field, it has an expiry date.
Leap seconds: the edge case that matters less than you think
Unix time explicitly ignores leap seconds — the count pretends every day has exactly 86,400 seconds. When a leap second is inserted, Unix time either repeats a second or smears the adjustment over a window, depending on the system. For virtually all application code, this doesn't matter: your event timestamps, your cron jobs, your log ordering are all fine. The only contexts where leap seconds are relevant are high-precision timing (GPS, astronomical observation, financial exchange matching at sub-second resolution). If you're building one of those systems, you already know. If you're not, the correct response to "but what about leap seconds?" is "Unix time handles it well enough, and my application doesn't need sub-second accuracy across day boundaries."
Storing dates without times
Not every timestamp needs a time component, and forcing one creates bugs. A birthdate, an invoice due date, or a holiday are calendar dates — they refer to a day, not a moment. Storing a birthdate as 1990-03-15T00:00:00Z is technically a moment in time, and converting that moment to a different timezone can shift it to March 14 — wrong birthday, wrong day. The safe pattern is to store date-only values as a plain YYYY-MM-DD string or a date column with no timezone attachment. Adding a time and a timezone to something that is inherently a date creates a class of bug that only surfaces when the viewer and the storage are in different timezones — which is exactly when you discover it matters.
Database timestamp types
Database engines offer multiple timestamp types, and choosing the wrong one is a common source of timezone bugs that show up months after deployment. PostgreSQL's TIMESTAMP WITHOUT TIME ZONE stores a local time with no offset — the database has no idea what timezone it refers to. TIMESTAMP WITH TIME ZONE (TIMESTAMPTZ) stores a UTC instant and converts to the session timezone on read. MySQL's DATETIME stores a naive local time; its TIMESTAMP type converts to UTC on write and back on read. SQLite stores whatever you give it as text or a number. The mismatch between "what I stored" and "what I got back" is almost always a column-type issue. For instants (when something happened), use the timezone-aware type. For calendar dates, use a date type. For user-entered local times that should not be shifted, use the naive type deliberately and document why.
Convert at the boundary, deliberately
Make the conversions explicit instead of hoping the language does the right thing. A timestamp converter turns Unix time into ISO 8601 and back, so you can confirm a value is what you think — and immediately catch the seconds-versus-milliseconds slip when the date comes out in 1970. A timezone converter resolves what an offset-bearing timestamp actually means in a given place. And a date calculator handles adding and subtracting durations without hand-rolling epoch arithmetic. Store instants as Unix time or offset-explicit ISO 8601, convert only at the edges, and the boundary stops being where your date bugs live.
← All articles