ISO 8601 vs Unix epoch vs human-readable: when to use which.
Three ways to represent a date, three contexts where each one wins — and the five time-handling mistakes I see in production code often enough that I stopped being surprised.
I was in a meeting once where a bug report read, roughly: "the timestamp on this record is wrong by a few hours." The backend engineer said the issue was that the frontend was sending a string instead of a timestamp. The frontend engineer said their timestamps were fine, the backend was truncating them. The data engineer said well, that depends whether you mean seconds or milliseconds.
Three smart people, three different mental models, a root cause that was partly all of them and partly none of them. The real issue was that "timestamp" doesn't mean one thing in most codebases. It means whatever shape the last person who touched that field happened to have in their hands.
This piece is about sorting that out. There are exactly three date representations you'll see in a professional codebase, each one has a context where it's the right answer, and most of the bugs I've debugged around dates come from using one where another belonged.
The meeting where everyone was right and wrong.
Here's what the bug actually was. The frontend sent 1728394295. The backend interpreted it as milliseconds (new Date(1728394295) → January 21, 1970, 04:46:34). The column in the database said "wrong by a few hours" because the frontend engineer was looking at their own computer's clock in local time and the column was showing UTC.
Every participant was correct about their own slice. But nobody had a name for the thing they were arguing about, because "timestamp" collapsed three distinct questions: what's the encoding, what's the unit, and what's the timezone. Sorting out those three up front is 90% of getting dates right.
The three honest representations.
Every date representation you've ever seen falls into one of three buckets:
Unix epoch
A single number: the count of seconds (or milliseconds) since January 1, 1970, UTC. Compact. Unambiguous. Trivially sortable as a number. Has no concept of timezone — it always means "this exact moment in physical time, globally."
1728394295 // seconds
1728394295000 // milliseconds
The seconds-vs-milliseconds split is the most common source of bugs in this bucket, by a large margin. Watch for it.
ISO 8601
A structured string with a strict format: year-month-day, then an optional T, then time, then an optional timezone offset (or Z for UTC). Human-auditable. Machine-parseable. Sortable alphabetically (which is why it's safe to use as a database column or filename).
2024-10-08T14:11:35Z // offset datetime (UTC)
2024-10-08T10:11:35-04:00 // offset datetime with explicit zone
2024-10-08T14:11:35 // local datetime (no zone — ambiguous!)
2024-10-08 // date only
The spec is surprisingly large — it includes weeks, ordinal dates, intervals, durations — but the 99% subset most code uses is the above four shapes.
Human-readable display
Whatever format your users expect to see: "October 8, 2024," "8 Oct 2024," "yesterday at 10:11 AM," "2 hours ago." These formats are good for humans and terrible for everything else. They vary by locale. They can't be sorted meaningfully. They can't be parsed reliably.
Display formats should live exactly at the edge of your system, converted at the last possible moment from one of the other two representations.
When each format wins.
The rule I've converged on, after enough date bugs to fill a medium-sized book:
| Context | Format | Why |
|---|---|---|
| Database column | ISO 8601 or Unix epoch ms | Sortable, unambiguous, no locale surprises |
| API request/response | ISO 8601 with explicit zone | Self-describing, works across languages, no "what unit?" question |
| Log timestamps | Unix epoch ms or ISO 8601 UTC | Sortable; ISO is easier to eyeball |
| Cache keys / hashes | Unix epoch | Compact, no string escaping issues |
| Scheduled jobs / cron | ISO 8601 or cron syntax | Humans have to read and write it |
| Config files | ISO 8601 | Humans have to read and write it |
| UI: recent events | Relative display ("2 hours ago") | What users actually care about |
| UI: scheduled events | Locale-formatted datetime with zone name | Users need to know when to show up |
| UI: historical data | ISO 8601 or locale date | Precision matters more than friendliness |
The pattern underneath the table: persistence uses Unix or ISO 8601, APIs use ISO 8601, UIs use display formats, and conversions happen at the boundaries. If you treat the database or API layer as "storage" and the UI as "presentation," and never let presentation formats leak into storage, you eliminate an entire category of bugs.
Five mistakes I see regularly.
In rough order of how often they show up:
1. Confusing seconds and milliseconds. The Unix epoch can be counted in either. JavaScript's Date uses milliseconds. Python's time.time() returns a float of seconds. Postgres and MySQL store seconds by default. Kafka timestamps are milliseconds. When you cross any of those boundaries without thinking about the unit, you get a date off by a factor of 1000 — which lands you in either 1970 or the year 56,000, depending on direction.
A useful rule: if your timestamp integer is 10 digits, it's almost certainly seconds. If it's 13 digits, it's almost certainly milliseconds. If you can't tell, the usual convention in APIs is to name the field explicitly — created_at_ms, expires_at_seconds — and I genuinely cannot overstate how much pain this one naming habit prevents.
2. Sorting Unix timestamps as strings. If you store a Unix timestamp in a text column, or serialize it into a JSON file, it will still look like it sorts correctly — until you cross a digit boundary. "999999999" sorts after "1000000000" in lexicographic order. The fix is to either store as a number type, or pad to a fixed width, or use ISO 8601 (which does sort correctly as a string because of the year-first format).
3. .toString() on a Date in JavaScript. Returns a locale-formatted string in the browser's local timezone. Looks fine on your machine. Breaks on the server, where it returns a slightly different format. Breaks in other browsers, where it returns a yet-different format. Breaks when deployed to a region with a different locale. The correct method is almost always .toISOString(), which returns a consistent ISO 8601 UTC string regardless of environment.
4. Storing display strings in a database. I've seen columns that contained "Jan 8, 2024". You can't sort that column by date — "Apr 1" comes before "Jan 8" alphabetically. You can't filter by date range. You can't do arithmetic. You can't even reliably parse it, because the person who put it there used an inconsistent locale. If you find a column like this, fix it before you do anything else involving that data.
5. Using CST as a timezone identifier. CST means both US Central Standard Time and China Standard Time — a six-hour difference. IST means Indian Standard Time, Irish Standard Time, and Israel Standard Time. Use IANA names (America/Chicago, Asia/Shanghai, Asia/Kolkata) for any timezone that will cross a system boundary. Abbreviations are fine in UI strings for humans; they're not fine in code.
The meta-rule these all share: assume everyone who reads your timestamp will interpret it differently than you do, and label it enough to remove the ambiguity. A field called timestamp is a landmine. A field called created_at_iso or expires_at_unix_ms answers its own question.
Quick reference.
If you only remember one thing: persistence is Unix or ISO 8601, APIs are ISO 8601 with an explicit zone, UIs are display formats, and conversions happen at the boundary — never in the middle.
If you remember three things, add: ISO 8601 sorts alphabetically, Unix epoch needs a unit label, and local time without a timezone is a bug waiting to happen.
If you remember five things: name your fields with the unit in them — that alone will prevent most of the bugs in this piece.
If you just want to convert one.
Half of this territory is theoretical. The other half is the very concrete moment when you're staring at 1728394295 in a log line and want to know what day that is in my local timezone, or you've got an ISO 8601 string in an API response and need the Unix seconds to hash into a cache key.
Every representation, at once.
Paste any timestamp — Unix seconds, milliseconds, ISO 8601, or almost any human date — and see all seven other forms, in your selected timezone, with DST handled honestly. Copy any format with one click.
Open the Timestamp Converter