← back to latest

Turn a Unix timestamp into a readable date in any timezone

TZ=America/New_York date -d @1788373391 +'%F %T %Z'

anatomy

TZ=America/New_York
A variable assignment placed before a command applies to that command only, and never touches your shell or your system clock. The C library reads TZ every time it converts an epoch value to a wall-clock time, so this reroutes the lookup into /usr/share/zoneinfo for exactly one invocation. Run it twice with two different zones and your own clock is unchanged both times.
date
Print or convert time. By default it reports now, but with an input option it becomes a general purpose converter between the two representations of a moment: a count of seconds, and a date a person can read.
-d @1788373391
Interpret the argument as a time to display rather than printing now. The leading @ is the important character: it switches the parser out of human date mode and tells it to read the number as seconds since the epoch. Without the @, a bare 10-digit number is not a date GNU date recognises and the command fails.
+'%F %T %Z'
The output format. %F is the full date (2026-09-02), %T is the time as %H:%M:%S, and %Z is the zone abbreviation. Keep %Z in the format while you are working: it is the only part of the line that proves which zone the conversion actually used.

Sample output

$ TZ=America/New_York date -d @1788373391 +'%F %T %Z'
2026-09-02 14:23:11 EDT

$ TZ=Asia/Tokyo date -d @1788373391 +'%F %T %Z'
2026-09-03 03:23:11 JST

One number, two answers, and they do not fall on the same calendar day. The epoch value is a fixed point that never changes; the date you read off it depends entirely on where you agree to be standing. That gap is why an incident timeline assembled from raw timestamps and one assembled from locally rendered log output can disagree about which event came first.

When you would reach for it

A log line, a JSON field, or a database column hands you a bare number like 1788373391 and you need to know when that was, in the zone the person you are talking to actually lives in. It also settles the recurring argument during an incident review, where one engineer is reading UTC off a dashboard and another is reading local time off a terminal, and the two accounts of the outage do not line up. Converting explicitly, with the zone printed alongside, replaces the mental arithmetic that people reliably get wrong at exactly the moment it matters most.

Gotchas

  • On macOS the same flag means something else entirely. BSD date uses -r for epoch input (date -r 1788373391), while GNU -r is --reference=FILE and reports a file’s modification time. BSD -d is not an input option at all, it sets the kernel’s daylight saving value, so a Linux snippet pasted onto a Mac errors instead of converting. Going the other way is worse: a Mac snippet on Linux returns date: 1788373391: No such file or directory, which sends people hunting for a missing file that was never involved. brew install coreutils gives you gdate, which takes the GNU form.
  • A 13-digit timestamp fails without telling you. JavaScript’s Date.now() and most Java and JSON APIs emit milliseconds, and date -d @1788373391123 returns +58641-04-19 02:25:23 and exits 0. There is no error to catch, only a date in the year 58641 sitting in your output. Check the digit count before you convert: 10 digits are seconds, 13 are milliseconds.
  • A misspelled zone name also fails silently. TZ=America/Nowhere date -d @1788373391 +'%F %T %Z' prints 2026-09-02 18:23:11 America and exits 0, having quietly fallen back to UTC and used your typo as the zone abbreviation. Keeping %Z in the format string is what catches this: a zone abbreviation you do not recognise means the lookup missed.
  • Zone abbreviations are not unique and are not safe to compute with. EDT and EST are the same location four months apart, and IST is claimed by India, Ireland, and Israel. Use full Area/City names from /usr/share/zoneinfo for input, and treat %Z in the output as a label for humans rather than something to parse.

Variants

$ date -u -d @1788373391 +'%Y-%m-%dT%H:%M:%SZ'

ISO 8601 in UTC, the format to use when the timestamp is going into a file, a ticket, or anywhere another program will read it

$ MS=1788373391123; date -u -d "@${MS:0:10}.${MS:10}" +'%F %T.%3N'

Convert a millisecond timestamp without losing the milliseconds. Bash substring expansion splits the number at the tenth digit and rebuilds it as a decimal, which GNU date accepts directly, and %3N prints the fraction back out. Dividing by 1000 in arithmetic would truncate it away

$ awk '{ $1 = strftime("%F %T", $1); print }' epoch.log

Rewrite an entire column of epoch values in place while leaving the rest of each line alone. Both gawk and mawk provide strftime, and it honours TZ the same way date does, so prefixing the command converts the whole file into any zone at once

lineage

The epoch that all of this counts from is an accident of arithmetic. The first edition of Unix counted time in sixtieths of a second from the start of 1971, which sounded precise until someone worked out that a 32-bit counter ticking at 60 Hz wraps in a little over two years. The fix was to slow the clock to one tick per second and move the origin back to midnight UTC on 1 January 1970, which bought several decades instead of a couple of years. The date command itself is old enough to be in that first edition manual, one of the small set of tools present before Unix left Bell Labs. The human-readable parser behind the -d option came much later and from outside the original lineage: by most accounts it began as Steve Bellovin's getdate, passed through Rich Salz and Jim Berets among others, and was eventually absorbed into the GNU tools, which is why -d can accept next friday and 2 weeks ago as readily as it accepts a raw epoch.