Skip to content
shellhacks.dev
APIs

How to Handle Time Zones and Timestamps in API Scripts

Handle API timestamps and time zones in scripts: ISO 8601 in UTC, epoch seconds versus milliseconds, GNU and BSD date, jq and daylight saving traps.

7 min read
Summarize in AI
Illustration for "How to Handle Time Zones and Timestamps in API Scripts": a terminal session.

Most bugs in scripts that talk to APIs involving time are not about calculations but about assumptions: that a timestamp has a time zone, that "seconds" were not milliseconds, that a day has 24 hours. A few rules remove most of them, and a handful of commands convert reliably on Linux and macOS. This post gives both. It relates to the date command on macOS and Linux and rate limit handling. Examples use GNU date 9.4, BSD date on macOS 15 and jq 1.7.

The rules

  • Use UTC internally. Convert to a local zone only at the edge, for display.
  • Send and store timestamps as RFC 3339 / ISO 8601 with an explicit zone (2026-11-03T09:12:44Z or +01:00), or as epoch seconds. Never send a bare local time without an offset.
  • Know the unit of an epoch number: 10 digits is seconds, 13 is milliseconds, 16 is microseconds.
  • Compare as numbers (epoch) or as ISO strings in the same zone; do not compare locale-formatted dates.

Produce and parse

Shell
date -u +%Y-%m-%dT%H:%M:%SZ
date +%s
date -u -d '2026-11-03T09:12:44Z' +%s                       # GNU
date -u -j -f '%Y-%m-%dT%H:%M:%SZ' '2026-11-03T09:12:44Z' +%s   # BSD/macOS
date -u -d @1793524364 +%FT%TZ                               # GNU: epoch to ISO
date -u -r 1793524364 +%FT%TZ                                # BSD: epoch to ISO
Text
2026-11-03T09:12:44Z
1793524364
1793524364

The first command is the timestamp to send in a request. GNU date parses many formats with -d; BSD requires the format string with -f and -j (do not set the clock). If a script must run on both, wrap the difference, as in scripts for macOS and Linux, or do the conversion in jq or Python.

Let jq do it

Shell
echo '{"created":"2026-11-03T09:12:44Z"}' | jq '.created | fromdateiso8601'
echo '{"created":"2026-11-03T09:12:44.512Z"}' | jq '.created | sub("\\.[0-9]+Z$";"Z") | fromdateiso8601'
echo '{"ts":1793524364}' | jq '.ts | todate'
echo '{"ms":1793524364512}' | jq '.ms / 1000 | floor | todate'
jq -n 'now - 86400 | todate'
Text
1793524364
1793524364
"2026-11-03T09:12:44Z"
"2026-11-03T09:12:44Z"

jq's date functions work the same on every platform. fromdateiso8601 expects exactly YYYY-MM-DDTHH:MM:SSZ and fails on fractional seconds or numeric offsets, so strip them with sub, as in the second example. todate turns epoch seconds into ISO. For milliseconds, divide by 1000 first. A filter for "records newer than a day": select(.created | sub("\\.[0-9]+";"") | fromdateiso8601 > now - 86400).

Time zones for display

Shell
TZ=Europe/Paris date -d '2026-11-03T09:12:44Z' '+%F %T %Z'
TZ=America/New_York date -d '2026-03-08T06:30:00Z' '+%F %T %Z'
TZ=America/New_York date -d '2026-03-08T07:30:00Z' '+%F %T %Z'
Text
2026-11-03 10:12:44 CET
2026-03-08 01:30:00 EST
2026-03-08 03:30:00 EDT

The TZ variable selects the zone for one command. The last two lines show a daylight saving jump: an hour apart in UTC, two hours apart on the wall clock, and 02:30 does not exist on that date in New York. That is why scheduling and arithmetic belong in UTC. "Midnight yesterday" in a local zone is 23 or 25 hours ago on a DST day, so ask the API for UTC ranges and convert the boundary once.

Common traps

  • A server returns local time without an offset: ask for the zone in the documentation or the response headers, and never guess from your own machine.
  • The Date header and Retry-After use HTTP date format (Tue, 03 Nov 2026 09:12:44 GMT), parsed by GNU date directly. Retry-After may be seconds or a date.
  • Clock skew: when a signature or token depends on time, a machine that is minutes off fails with confusing errors. Check with timedatectl and keep NTP running.
  • Leap days, and the year 2038 for 32-bit epoch values, matter for old systems and stored data.
  • Pagination by timestamp can skip records written in the same second; use a cursor or an overlap window.

Where this does not apply

For anything beyond conversions, such as recurring events or business days, shell is the wrong tool; use a language with a time zone database and a date library. date relies on the system's tzdata, which needs updates when governments change rules. See jq for API responses for the rest of the JSON handling.

Choosing one representation and sticking to it

Time bugs come from mixing representations. An API can return seconds since the epoch, milliseconds since the epoch, an ISO 8601 string with a zone, a string with no zone, or a locale-formatted date meant for humans, and a script that guesses will be wrong on some of them. The dependable convention is to exchange and store times in UTC in the ISO 8601 form with a trailing Z, as in 2026-11-05T09:30:00Z, and to convert to a local zone only when displaying to a person. That form sorts correctly as plain text, so you can compare two timestamps with string comparison when they have the same format and zone. Watch for the epoch trap: a value of thirteen digits is milliseconds, ten digits is seconds, and treating one as the other gives a date in 1970 or in the year 50,000. A string without a zone designator is ambiguous, and the right action is to find out from the documentation what zone it means and never to assume the local one.

Offsets such as +05:30 are valid and common. Convert them to UTC before comparing, because two strings with different offsets cannot be compared as text.

Producing and parsing on different systems

The date command differs between systems, which makes portable scripts hard. To print the current time in UTC in the standard form, date -u +%Y-%m-%dT%H:%M:%SZ works on both GNU and BSD systems. To parse a string, GNU date -d "2026-11-05T09:30:00Z" +%s gives the epoch, while the BSD version needs date -j -f "%Y-%m-%dT%H:%M:%SZ" "..." +%s. The post on the differences between the two covers more of them. Setting the TZ variable for one command, as in TZ=UTC date, avoids depending on the machine's zone, and a script that does arithmetic on dates should set it explicitly. For anything beyond printing and converting, consider doing the work in jq or a language with a proper date library, since shell date arithmetic is error-prone around month ends and daylight saving changes.

When adding or subtracting time, work in epoch seconds, which are linear, and convert at the edges. Adding 86400 seconds to a local time across a daylight saving change does not give the same wall-clock time the next day.

What jq can and cannot do with dates

jq has date functions: fromdateiso8601 converts a string to epoch seconds, todateiso8601 or todate converts back, strptime and strftime parse and format with a pattern, and mktime and gmtime convert between broken-down time and seconds. They are enough for filtering by age, such as selecting items newer than a day with a comparison against now - 86400. The catch is that fromdateiso8601 accepts only the strict form with whole seconds and a Z suffix. A timestamp with fractional seconds, such as 2026-11-05T09:30:00.123Z, fails, and one with a numeric offset also fails in many versions, so strip the fraction with sub("\\.[0-9]+";"") and convert offsets before parsing. Check your version of jq, since behavior around time zones and the formatting functions has changed between releases. Test the filter on the exact strings the API returns, including edge cases such as midnight, a leap day and a missing value.

Time zones for display, and traps worth knowing

For output a person reads, convert at the very end. TZ=America/New_York date -d @1730799000 prints the epoch in a named zone, and the named zones follow the rules for daylight saving, which fixed offsets do not. Store the zone name when the local meaning matters, such as a meeting at 9 a.m. in a city, since the UTC time of that meeting changes when the clocks change. Several traps recur. Daylight saving makes some local times not exist and others occur twice. Day boundaries depend on the zone, so counting events per day gives different answers in different zones. Leap seconds are smoothed or ignored by most systems, which matters only for precision work. Clocks differ between machines, so a timestamp made by the client and one made by the server can disagree by seconds or minutes, and a script comparing them needs a tolerance. Rate limits and token expiries are given in seconds or as absolute times, and reading them in the wrong unit gives a wait of fifty thousand years or none at all.

See RFC 3339 and the jq manual on dates.

Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.