If you’ve written shell for more than a year, you’ve been bitten by date handling. The classic failures:
date -d "yesterday"works on Linux, fails silently or differently on macOS.- A backup script keeps
last 7 daysfiles; on the day clocks roll back for DST, it keeps 6 or 8 by accident. - Cron job logs are in
Sun Mar 10 02:30:00 EST 2024format; trying to sort by date alphabetically gives nonsense. - A locale change on the build server (
de_DEinstead ofen_US) makesdate +'%x'emit13.03.2024and breaks downstream parsers. - Comparing timestamps as strings:
"2024-03-10" < "2024-03-09T23:00:00"because the right side is “longer.”
This lesson is a complete, defensive treatment of date handling in shell:
- ISO 8601 as the only acceptable wire format (and why).
- The GNU vs BSD
datechasm and how to write scripts that don’t care. - The
TZ=environment variable and why every cron-related script should set it explicitly. - Time arithmetic: yesterday, N-days-ago, last Monday, end-of-month, sleep-until-3am.
- Epoch (Unix-time) conversions both directions, including the 2038 wrap.
- DST, leap seconds, locale, and the other dragons.
- A reusable
lib/time.shyou can drop into any project.
By the end, your scripts will produce timestamps that sort correctly, parse the same on any machine, and survive DST.
1. ISO 8601 — the only date format you should write
ISO 8601 is the international standard for date/time strings. It looks like:
2024-03-10 # date
2024-03-10T14:30:00 # datetime, local
2024-03-10T14:30:00Z # datetime, UTC ('Z' = Zulu = UTC)
2024-03-10T14:30:00+05:30 # datetime, with offset
2024-03-10T14:30:00.123456Z # with sub-second precision
Why it’s mandatory for scripts:
- Sorts lexically:
2024-03-10<2024-03-11as strings, no parser needed. - Unambiguous:
03/10/2024is March 10 in the US and October 3 in Europe. ISO 8601 has one meaning everywhere. - Locale-immune: doesn’t depend on
LC_TIME. - Universally parseable: every language’s stdlib understands it.
Producing ISO 8601 from date
GNU coreutils gives you a shortcut:
date -Iseconds # 2024-03-10T14:30:00+05:30
date -Iseconds -u # 2024-03-10T09:00:00+00:00
date -u +%Y-%m-%dT%H:%M:%SZ # 2024-03-10T09:00:00Z (portable)
The last line is the portable form — it works on macOS, BSDs, Linux, busybox, alpine, everywhere. Use that.
A canonical iso_now function
# UTC, second precision, ISO 8601 with literal Z suffix.
iso_now() {
date -u '+%Y-%m-%dT%H:%M:%SZ'
}
# With nanosecond precision (GNU only — falls back to seconds elsewhere).
iso_now_ns() {
if date -u '+%Y-%m-%dT%H:%M:%S.%NZ' 2>/dev/null | grep -qv '%N'; then
date -u '+%Y-%m-%dT%H:%M:%S.%NZ'
else
iso_now
fi
}
Use iso_now for log timestamps, file naming (backup-2024-03-10T14:30:00Z.tar.gz), database INSERTs, anything that’s read by another process.
Why Z and not +00:00?
Both mean UTC. Z is one character shorter, less ambiguous to a quick reader, and matches what RFC 3339 (a stricter ISO 8601 subset used in HTTP, JSON APIs, etc.) prefers. +00:00 is also valid; pick one and stick to it.
2. GNU vs BSD date — the cross-platform chasm
There are two major date implementations:
- GNU coreutils
date— Linux, alpine, WSL. Extremely flexible:date -d "yesterday",date -d "next Monday",date -d "@1710000000". - BSD
date— macOS, FreeBSD, OpenBSD. Different flag style:date -v -1dfor “yesterday”,date -j -ffor parsing.
A script written for one will silently misbehave on the other. The differences:
| Task | GNU | BSD (macOS) |
|---|---|---|
| Yesterday | date -d 'yesterday' |
date -v -1d |
| Last week | date -d '7 days ago' |
date -v -7d |
| Specific epoch | date -d '@1710000000' |
date -r 1710000000 |
| Parse a string | date -d '2024-03-10 14:30' |
date -j -f '%Y-%m-%d %H:%M' '2024-03-10 14:30' |
| Format a date | +%Y-%m-%d (same) |
+%Y-%m-%d (same) |
| Force UTC | -u (same) |
-u (same) |
The +FORMAT and -u flags are common ground. Everything that involves arithmetic or parsing differs.
Detection pattern
# Returns "gnu" or "bsd" — sets a global once.
detect_date() {
if date --version 2>/dev/null | grep -q 'GNU'; then
echo gnu
else
echo bsd
fi
}
DATE_IMPL=$(detect_date)
date --version exists on GNU but errors on BSD; that’s the cleanest test.
Portable wrappers
The pragmatic approach: write thin wrappers that branch internally and call them everywhere.
# yesterday — print yesterday's date in YYYY-MM-DD
yesterday() {
if [[ $DATE_IMPL == gnu ]]; then
date -u -d 'yesterday' +%Y-%m-%d
else
date -u -v -1d +%Y-%m-%d
fi
}
# n_days_ago N — print the date N days ago
n_days_ago() {
local n=$1
if [[ $DATE_IMPL == gnu ]]; then
date -u -d "$n days ago" +%Y-%m-%d
else
date -u -v "-${n}d" +%Y-%m-%d
fi
}
# epoch_to_iso EPOCH — convert seconds-since-epoch to ISO 8601 UTC
epoch_to_iso() {
local epoch=$1
if [[ $DATE_IMPL == gnu ]]; then
date -u -d "@$epoch" '+%Y-%m-%dT%H:%M:%SZ'
else
date -u -r "$epoch" '+%Y-%m-%dT%H:%M:%SZ'
fi
}
# iso_to_epoch 'YYYY-MM-DDTHH:MM:SSZ' — reverse
iso_to_epoch() {
local iso=$1
if [[ $DATE_IMPL == gnu ]]; then
date -u -d "$iso" +%s
else
# BSD: must specify the format. Strip Z, parse as UTC explicitly.
iso=${iso%Z}
TZ=UTC date -u -j -f '%Y-%m-%dT%H:%M:%S' "$iso" +%s
fi
}
Drop those four functions in a project’s lib/time.sh and 90% of your portability problems go away.
The “just install GNU date on macOS” tip
On macOS, brew install coreutils installs GNU coreutils with a g prefix: gdate, gls, gsed, etc. If you require GNU date, do this and use gdate directly:
DATE=$(command -v gdate || command -v date)
"$DATE" -d 'yesterday' +%Y-%m-%d
For team-internal scripts this is often the cleanest answer. For tools you ship to others, write the portable wrappers.
3. The TZ= environment variable — your most important friend
Time zones are global state in your shell. date reads TZ (or falls back to system zone) to pick the displayed time. This is the source of about 80% of “works on my machine” date bugs.
The classic bug
$ date '+%Y-%m-%d %H:%M:%S'
2024-03-10 18:30:00
You write a backup that names files by this date. Same script runs on a server in America/Los_Angeles:
$ date '+%Y-%m-%d %H:%M:%S'
2024-03-10 06:00:00
Now the file naming is inconsistent across hosts. Search by date is broken.
The rule: everything in scripts is UTC, everything in human output can be local.
# WRONG — local time, host-dependent.
LOG_DATE=$(date '+%Y-%m-%d')
# RIGHT — UTC, deterministic.
LOG_DATE=$(date -u '+%Y-%m-%d')
For any timestamp that ends up in a filename, log line, database row, S3 key, or anywhere it might be read by another process: always UTC, always ISO 8601.
Setting TZ explicitly per command
You can override timezone for a single invocation:
TZ=UTC date '+%H:%M' # 14:30
TZ=America/New_York date '+%H:%M' # 10:30
TZ=Asia/Kolkata date '+%H:%M' # 20:00
This is invaluable for displaying user-facing times in their zone:
event_in_user_tz() {
local event_iso=$1 user_tz=$2
TZ="$user_tz" date -d "$event_iso" '+%a %b %d, %I:%M %p %Z'
}
event_in_user_tz '2024-03-10T14:00:00Z' 'America/New_York'
# Sun Mar 10, 10:00 AM EDT
Cron and TZ — the gotcha that bites every team
Cron daemons run with their own environment. Most cron jobs do not inherit TZ from your login shell. Common defaults:
cronon Debian/Ubuntu: uses system timezone (/etc/timezone).cronon Red Hat-likes: same.- Docker containers: usually UTC unless you specifically set
TZ. - Kubernetes CronJobs: UTC unless the pod sets
TZenv var. - macOS
launchd: reads system zone.
The pattern: at the top of every cron-invoked script, set TZ explicitly.
#!/usr/bin/env bash
set -Eeuo pipefail
export TZ=UTC # Now `date` is deterministic.
LOG_DATE=$(date '+%Y-%m-%d')
# ... rest of script
If your script needs to display a local time anywhere, that’s where you override per-invocation as above.
Where the system gets TZ from
Order of precedence:
TZenvironment variable (if set, wins)./etc/localtimesymlink (on most Linuxes).- Compiled-in default (UTC).
To check what the current zone is:
date '+%Z %z' # IST +0530
ls -l /etc/localtime # → /usr/share/zoneinfo/Asia/Kolkata
timedatectl # systemd-based Linux
timedatectl set-timezone UTC is the right way to change a server’s zone (don’t edit /etc/localtime by hand).
4. Time arithmetic — yesterday, last week, N days ago
The most common script need is “give me a date relative to now.”
Yesterday / today / tomorrow
yesterday() { n_days_ago 1; }
today() { date -u +%Y-%m-%d; }
tomorrow() { n_days_ahead 1; }
n_days_ago() {
local n=$1
if [[ $DATE_IMPL == gnu ]]; then
date -u -d "$n days ago" +%Y-%m-%d
else
date -u -v "-${n}d" +%Y-%m-%d
fi
}
n_days_ahead() {
local n=$1
if [[ $DATE_IMPL == gnu ]]; then
date -u -d "$n days" +%Y-%m-%d
else
date -u -v "+${n}d" +%Y-%m-%d
fi
}
Last hour / N hours ago
n_hours_ago() {
local n=$1
if [[ $DATE_IMPL == gnu ]]; then
date -u -d "$n hours ago" '+%Y-%m-%dT%H:%M:%SZ'
else
date -u -v "-${n}H" '+%Y-%m-%dT%H:%M:%SZ'
fi
}
Note: BSD uses uppercase H for hours, lowercase h is not valid (it errors). Always uppercase: H, M (months), m (minutes), d, y. The case-sensitivity is a constant footgun.
Last week (a specific weekday in the past)
GNU date understands phrases like last Monday, last Tuesday, etc.:
last_monday=$(date -u -d 'last Monday' +%Y-%m-%d)
last_friday=$(date -u -d 'last Friday' +%Y-%m-%d)
BSD does not have a direct equivalent. You have to compute it manually:
# Returns the most recent Monday (or today if today is Monday).
last_monday_bsd() {
local today_dow
today_dow=$(date -u +%u) # 1=Mon..7=Sun
local back=$(( (today_dow + 6) % 7 ))
date -u -v "-${back}d" +%Y-%m-%d
}
Or, far simpler, use the GNU coreutils on macOS:
gdate -u -d 'last Monday' +%Y-%m-%d
Start / end of month
The standard trick: navigate to the first of next month, subtract one day.
end_of_month() {
local year_month=$1 # 'YYYY-MM'
if [[ $DATE_IMPL == gnu ]]; then
date -u -d "$year_month-01 + 1 month - 1 day" +%Y-%m-%d
else
# BSD: -v +1m and -v -1d, anchored to the first of the month.
TZ=UTC date -j -f '%Y-%m-%d' "$year_month-01" -v +1m -v -1d +%Y-%m-%d
fi
}
end_of_month 2024-02 # 2024-02-29 (leap year, correctly)
end_of_month 2024-03 # 2024-03-31
Time-window queries: “logs from the last N hours”
Useful for log scrapers and metrics:
window_iso() {
local hours=$1
local now_iso since_iso
now_iso=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
since_iso=$(n_hours_ago "$hours")
printf '%s..%s\n' "$since_iso" "$now_iso"
}
window_iso 24 # 2024-03-09T14:30:00Z..2024-03-10T14:30:00Z
Now you can do journalctl --since="$since_iso" --until="$now_iso" (journalctl accepts ISO 8601 directly).
5. Epoch (Unix time) — the universal interchange format
The Unix epoch is “seconds since 1970-01-01T00:00:00Z” as an integer. It’s:
- Single number, no time zone, no formatting choices.
- Trivially comparable:
[[ $a -lt $b ]]. - The format every system uses internally.
Now in epoch
date +%s # 1710081000
%s is the same on GNU and BSD. Always portable.
Bash’s EPOCHSECONDS (4.5+)
echo "$EPOCHSECONDS" # bash 5.0+
echo "$EPOCHREALTIME" # microsecond precision
EPOCHSECONDS doesn’t fork date, which matters in tight loops. Available since bash 4.5 (EPOCHREALTIME since 5.0). Not available in dash/POSIX sh.
Epoch arithmetic — the easiest way to do time math
now=$(date +%s)
five_min_ago=$((now - 300))
# or:
sleep_until=$((now + 600))
Epoch math is timezone-, locale-, and DST-immune. It’s just integer arithmetic. When in doubt, convert to epoch, do the math, convert back.
Convert epoch → ISO 8601
epoch_to_iso() {
local epoch=$1
if [[ $DATE_IMPL == gnu ]]; then
date -u -d "@$epoch" '+%Y-%m-%dT%H:%M:%SZ'
else
date -u -r "$epoch" '+%Y-%m-%dT%H:%M:%SZ'
fi
}
epoch_to_iso 1710081000 # 2024-03-10T14:30:00Z
Convert ISO 8601 → epoch
iso_to_epoch() {
local iso=$1
if [[ $DATE_IMPL == gnu ]]; then
date -u -d "$iso" +%s
else
iso=${iso%Z}
TZ=UTC date -j -f '%Y-%m-%dT%H:%M:%S' "$iso" +%s
fi
}
iso_to_epoch '2024-03-10T14:30:00Z' # 1710081000
The “elapsed since” pattern
Common in monitoring and alerting:
event_iso='2024-03-10T14:30:00Z'
event_epoch=$(iso_to_epoch "$event_iso")
now=$(date +%s)
elapsed=$(( now - event_epoch ))
if (( elapsed > 3600 )); then
printf 'Event was %d seconds (%.1fh) ago — alerting.\n' \
"$elapsed" "$(awk "BEGIN{print $elapsed/3600}")"
fi
The 2038 problem
32-bit signed time_t overflows on 2038-01-19T03:14:07Z. After that, scripts using 32-bit epoch values wrap to negative numbers (December 1901).
In practice:
- All 64-bit Linux/macOS distros use 64-bit
time_t. You’re fine. - Some embedded/IoT/old-router systems still use 32-bit. Test before relying on dates past 2038.
- Bash’s integer arithmetic is at least 64-bit on any modern build, regardless of
time_t.
6. Sleep until a specific time
A surprisingly common need: “wait until 3 AM.” Cron does this for you, but for in-script delays (e.g. wait for an API rate-limit reset), you compute the gap.
Sleep until next 03:00 UTC
sleep_until() {
local target_iso=$1
local target_epoch now sleep_for
target_epoch=$(iso_to_epoch "$target_iso")
now=$(date +%s)
sleep_for=$(( target_epoch - now ))
if (( sleep_for > 0 )); then
printf 'Sleeping %d seconds until %s\n' "$sleep_for" "$target_iso" >&2
sleep "$sleep_for"
else
printf 'Target %s already passed (%d seconds ago)\n' \
"$target_iso" "$(( -sleep_for ))" >&2
fi
}
# Wait until 3 AM tomorrow UTC.
target=$(date -u -d 'tomorrow 03:00' '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null \
|| TZ=UTC date -j -v +1d -v 3H -v 0M -v 0S '+%Y-%m-%dT%H:%M:%SZ')
sleep_until "$target"
Wait until a time today, or tomorrow if already past
sleep_until_hour_utc() {
local hour=$1 # 0-23
local now today_target tomorrow_target target now_epoch
now_epoch=$(date +%s)
today_target=$(date -u +%Y-%m-%d)T${hour}:00:00Z
if [[ $DATE_IMPL == gnu ]]; then
tomorrow_target=$(date -u -d 'tomorrow' +%Y-%m-%d)T${hour}:00:00Z
else
tomorrow_target=$(date -u -v +1d +%Y-%m-%d)T${hour}:00:00Z
fi
if (( $(iso_to_epoch "$today_target") > now_epoch )); then
sleep_until "$today_target"
else
sleep_until "$tomorrow_target"
fi
}
sleep_until_hour_utc 3 # Sleep until next 03:00 UTC.
7. Daylight Saving Time — the hour that doesn’t exist
DST is the largest source of date-arithmetic bugs in shell scripts. The two pathological cases:
- Spring forward: 02:00 jumps to 03:00. The hour 02:00–02:59 does not exist on that day. A timestamp like
02:30is undefined locally. - Fall back: 02:00 happens twice. A local timestamp like
01:30is ambiguous on that day.
If you do anything in a DST-observing zone, you can hit these. The fix is simple: do arithmetic in UTC. UTC has no DST.
# DST-correct way to compute "23 hours ago":
now=$(date +%s)
since=$((now - 23*3600))
since_iso=$(epoch_to_iso "$since") # Always 23 real hours, no DST shifts.
# DST-fragile way:
since=$(date -d 'yesterday' '+%H:%M') # Could be 22:00 or 24:00 (= 00:00 next day) on DST days.
DST-aware “1 day ago” — the operational nuance
If you literally want “the same wall-clock time yesterday in this zone,” date -d 'yesterday' is correct, but the duration between now and yesterday-same-time can be 23, 24, or 25 hours depending on DST.
For most operations (log retention, backup pruning, cache TTLs), you want 24 hours of real time, not “yesterday at this clock time.” Use epoch arithmetic.
# Real 24 hours ago.
yesterday_epoch=$(( $(date +%s) - 86400 ))
yesterday_iso=$(epoch_to_iso "$yesterday_epoch")
Summary rule
| Operation | Use |
|---|---|
| Log retention (“delete files older than N days”) | Epoch arithmetic |
| Cron scheduling (“run every day at 3 AM local”) | Wall-clock (cron handles DST for you) |
| Display (“when did X happen?”) | Local time, format only |
| Wire interchange | UTC ISO 8601 always |
8. Locale — the silent saboteur
date reads LC_TIME (or LC_ALL / LANG) for month names, day names, and the %x %X %c format specifiers.
$ LC_ALL=de_DE.UTF-8 date '+%a %b %d, %x'
So Mär 10, 10.03.2024
$ LC_ALL=en_US.UTF-8 date '+%a %b %d, %x'
Sun Mar 10, 03/10/2024
$ LC_ALL=fr_FR.UTF-8 date '+%a %b %d, %x'
dim. mars 10, 10/03/2024
If your script generates a date for use by another script, always lock the locale:
export LC_ALL=C # POSIX/C locale = English, predictable.
date '+%a %b %d %H:%M:%S %Y' # Sun Mar 10 14:30:00 2024 — always.
LC_ALL=C (or C.UTF-8) is the right setting at the top of every script that emits or parses dates by name. It makes %a/%b/%A/%B always English and predictable, regardless of how the host is configured.
When to use LC_ALL=C vs LC_ALL=C.UTF-8
Cis bytes-as-bytes; safest, smallest behaviour set, no UTF-8 awareness.C.UTF-8is “C locale but with UTF-8 character handling” — better if you need to print non-ASCII in error messages while still locking date format.
Both fix the date issue. Pick C.UTF-8 if your script may print user data containing non-ASCII; otherwise C is the maximally portable choice.
9. Comparing dates correctly
ISO 8601 dates compare correctly as strings:
[[ '2024-03-10' < '2024-03-11' ]] # true (lexical)
[[ '2024-03-10T14:30:00Z' < '2024-03-10T14:30:01Z' ]] # true (lexical)
This is the killer feature. No other format does this. 03/10/2024 < 03/11/2024 happens to work but 12/31/2024 < 01/01/2025 does not (the strings sort 01/01/2025 < 12/31/2024).
For numeric comparison, convert to epoch:
a=$(iso_to_epoch '2024-03-10T14:30:00Z')
b=$(iso_to_epoch '2024-03-11T09:00:00Z')
if (( a < b )); then echo "a is earlier"; fi
Diff between two dates in seconds
diff_seconds() {
local a_iso=$1 b_iso=$2
echo $(( $(iso_to_epoch "$b_iso") - $(iso_to_epoch "$a_iso") ))
}
diff_seconds '2024-03-10T14:30:00Z' '2024-03-10T14:31:00Z' # 60
Diff in days, hours, minutes
diff_human() {
local sec=$1
if (( sec < 60 )); then printf '%ds\n' "$sec"
elif (( sec < 3600 )); then printf '%dm %ds\n' $((sec/60)) $((sec%60))
elif (( sec < 86400 )); then printf '%dh %dm\n' $((sec/3600)) $(((sec%3600)/60))
else printf '%dd %dh\n' $((sec/86400)) $(((sec%86400)/3600))
fi
}
diff_human 90 # 1m 30s
diff_human 3700 # 1h 1m
diff_human 123456 # 1d 10h
10. Cron-safe timestamps
When you generate a filename or log entry from a cron job, follow this exact recipe:
#!/usr/bin/env bash
set -Eeuo pipefail
export TZ=UTC # Don't trust the cron environment.
export LC_ALL=C # Don't trust the locale.
iso_now() { date '+%Y-%m-%dT%H:%M:%SZ'; }
LOG_FILE="/var/log/myjob/myjob-$(iso_now).log"
exec >"$LOG_FILE" 2>&1
printf '[%s] starting myjob\n' "$(iso_now)"
# ... actual work ...
printf '[%s] myjob done\n' "$(iso_now)"
That preamble — set -Eeuo pipefail + export TZ=UTC + export LC_ALL=C + an iso_now helper — should be the default skeleton for every cron-invoked script you write.
Why files named with timestamps need UTC
Two cron jobs on different hosts producing files named backup-$(date +%Y-%m-%d).tar.gz:
host-A (UTC): backup-2024-03-10.tar.gz
host-B (PST): backup-2024-03-09.tar.gz # same wall-clock event, different name
You merge them, sort by name, and it looks like host-B’s file is a day older than host-A’s. It’s not — they ran the same minute. The fix is date -u.
11. Reusable lib/time.sh
# lib/time.sh — drop-in time/date helpers. Source from any script.
# Requires bash 4+. Sets DATE_IMPL on first source.
if [[ -z ${DATE_IMPL:-} ]]; then
if date --version 2>/dev/null | grep -q 'GNU'; then
DATE_IMPL=gnu
else
DATE_IMPL=bsd
fi
export DATE_IMPL
fi
# Always-UTC ISO 8601 timestamp at second precision.
iso_now() {
date -u '+%Y-%m-%dT%H:%M:%SZ'
}
# Date-only YYYY-MM-DD in UTC.
today_utc() {
date -u '+%Y-%m-%d'
}
# Convert epoch seconds → ISO 8601 UTC.
epoch_to_iso() {
local epoch=$1
if [[ $DATE_IMPL == gnu ]]; then
date -u -d "@$epoch" '+%Y-%m-%dT%H:%M:%SZ'
else
date -u -r "$epoch" '+%Y-%m-%dT%H:%M:%SZ'
fi
}
# Convert ISO 8601 (UTC, with Z) → epoch seconds.
iso_to_epoch() {
local iso=$1
if [[ $DATE_IMPL == gnu ]]; then
date -u -d "$iso" +%s
else
iso=${iso%Z}
TZ=UTC date -u -j -f '%Y-%m-%dT%H:%M:%S' "$iso" +%s
fi
}
# N days ago, YYYY-MM-DD UTC.
n_days_ago() {
local n=$1
if [[ $DATE_IMPL == gnu ]]; then
date -u -d "$n days ago" +%Y-%m-%d
else
date -u -v "-${n}d" +%Y-%m-%d
fi
}
# N days ahead, YYYY-MM-DD UTC.
n_days_ahead() {
local n=$1
if [[ $DATE_IMPL == gnu ]]; then
date -u -d "$n days" +%Y-%m-%d
else
date -u -v "+${n}d" +%Y-%m-%d
fi
}
# N hours ago, ISO 8601 UTC.
n_hours_ago() {
local n=$1
if [[ $DATE_IMPL == gnu ]]; then
date -u -d "$n hours ago" '+%Y-%m-%dT%H:%M:%SZ'
else
date -u -v "-${n}H" '+%Y-%m-%dT%H:%M:%SZ'
fi
}
# Diff between two ISO timestamps in seconds (b − a).
diff_seconds() {
local a b
a=$(iso_to_epoch "$1")
b=$(iso_to_epoch "$2")
echo $(( b - a ))
}
# Human-readable duration from seconds.
diff_human() {
local s=$1
if (( s < 60 )); then printf '%ds' "$s"
elif (( s < 3600 )); then printf '%dm %ds' $((s/60)) $((s%60))
elif (( s < 86400 )); then printf '%dh %dm' $((s/3600)) $(((s%3600)/60))
else printf '%dd %dh' $((s/86400)) $(((s%86400)/3600))
fi
}
# Sleep until a specific ISO timestamp, no-op if past.
sleep_until() {
local target=$1 t_epoch now sleep_for
t_epoch=$(iso_to_epoch "$target")
now=$(date +%s)
sleep_for=$(( t_epoch - now ))
if (( sleep_for > 0 )); then
sleep "$sleep_for"
fi
}
Using it
#!/usr/bin/env bash
set -Eeuo pipefail
export TZ=UTC LC_ALL=C
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
source "$SCRIPT_DIR/lib/time.sh"
start_iso=$(iso_now)
printf '[%s] starting backup\n' "$start_iso" >&2
# ... do work ...
end_iso=$(iso_now)
elapsed=$(diff_seconds "$start_iso" "$end_iso")
printf '[%s] backup completed in %s\n' "$end_iso" "$(diff_human "$elapsed")" >&2
That’s a fully-portable, DST-immune, locale-immune, cron-safe skeleton.
12. Real-world recipes
Retention: delete files older than N days
prune_old() {
local dir=$1 days=$2
local cutoff
cutoff=$(( $(date +%s) - days*86400 ))
# find -mtime is portable but rounds to whole days. Epoch is precise.
while IFS= read -r -d '' f; do
local ftime
if [[ $DATE_IMPL == gnu ]]; then
ftime=$(stat -c %Y "$f")
else
ftime=$(stat -f %m "$f")
fi
if (( ftime < cutoff )); then
rm -- "$f"
printf 'Pruned: %s\n' "$f" >&2
fi
done < <(find "$dir" -type f -print0)
}
prune_old /var/log/myapp 30
The stat invocation is also GNU vs BSD different — the lib/time.sh pattern is to wrap it.
Daily snapshot directory naming
today=$(today_utc)
snapshot_dir="/snapshots/$today"
mkdir -p "$snapshot_dir"
rsync -aP --link-dest="/snapshots/$(n_days_ago 1)" /data/ "$snapshot_dir/"
ISO date as directory name → sorts correctly when listed. --link-dest to yesterday’s snapshot for hardlink-based incremental.
Run only between business hours (UTC)
hour_utc=$(date -u +%H)
hour_int=$((10#$hour_utc)) # force base-10 (avoid octal trap on 08, 09)
if (( hour_int < 9 || hour_int >= 17 )); then
printf 'Outside business hours (%s UTC), skipping.\n' "$hour_utc" >&2
exit 0
fi
The 10# prefix forces base-10 interpretation — without it, 08 and 09 would be parsed as invalid octal and error out. Always use 10#$var when doing arithmetic on zero-padded numbers from date.
Wait for a deadline, with progress
deadline_iso='2024-03-10T18:00:00Z'
deadline_epoch=$(iso_to_epoch "$deadline_iso")
while :; do
now=$(date +%s)
remaining=$(( deadline_epoch - now ))
if (( remaining <= 0 )); then
printf 'Deadline reached.\n' >&2
break
fi
printf '\rWaiting: %s remaining... ' "$(diff_human "$remaining")" >&2
sleep 1
done
echo >&2
Nightly batch cutoff: “include only events from yesterday”
yesterday_start=$(n_days_ago 1)T00:00:00Z
yesterday_end=$(today_utc)T00:00:00Z
# psql, BigQuery, S3 prefix, whatever — this gives you exactly one UTC day.
psql -c "SELECT * FROM events WHERE created_at >= '$yesterday_start' AND created_at < '$yesterday_end'"
UTC, half-open interval, ISO 8601. The “right” way to query a day in any database.
13. Edge cases & dragons
Leap seconds
UTC occasionally has a 60th second in a minute (e.g. 2016-12-31T23:59:60Z). Most date libraries silently flatten this. In shell:
datetypically does not produce:60even when one occurred.- Treat leap seconds as off-by-one curiosities; if you’re doing physics-grade timing you should not be in shell.
Year boundaries
2024-12-31 + 1 day = 2025-01-01. Verified — but watch out:
date -d '2024-12-31 + 1 day' # 2025-01-01 — OK on GNU
The arithmetic is correct. The bug surface is only if you try to do it manually with string slicing.
%j (day of year) and rollover
date -u +%j # 070 (March 10 = day 70 of year)
Useful for Julian-style file naming, not common in DevOps.
%V vs %U vs %W — week number
Three different week numberings:
%V— ISO 8601 week (week starts Monday, week 1 contains Jan 4). Use this.%U— Sunday-starting, week 1 starts on first Sunday.%W— Monday-starting, week 1 starts on first Monday.
If you’re emitting week numbers for any reason, use %V. It’s the only one with a fixed, well-defined rule across calendars.
macOS BSD date with no -d
A common surprise: date -d on macOS interprets the argument completely differently from GNU. On BSD, -d sets the DST flag (whether DST is in effect for the given time), not the date string. Don’t rely on -d working at all; use -v or -j -f.
14. Putting it all together — a backup retention script
A real cron script, using lib/errors.sh, lib/log.sh, and lib/time.sh:
#!/usr/bin/env bash
# /usr/local/bin/backup-rotate
# Daily UTC backup, keeps 7 daily + 4 weekly + 12 monthly snapshots.
set -Eeuo pipefail
export TZ=UTC LC_ALL=C
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
source "$SCRIPT_DIR/lib/errors.sh"
source "$SCRIPT_DIR/lib/log.sh"
source "$SCRIPT_DIR/lib/time.sh"
SNAP_ROOT=/var/backups/snapshots
SOURCE=/data
today=$(today_utc)
snap_dir="$SNAP_ROOT/$today"
log_info "Starting daily backup → $snap_dir"
# Hardlink-based incremental from yesterday if it exists.
yesterday_dir="$SNAP_ROOT/$(n_days_ago 1)"
if [[ -d $yesterday_dir ]]; then
log_info "Hardlinking from $yesterday_dir"
rsync -aP --link-dest="$yesterday_dir" "$SOURCE/" "$snap_dir/"
else
log_warn "No yesterday snapshot — full copy"
rsync -aP "$SOURCE/" "$snap_dir/"
fi
# Daily retention: keep last 7.
log_info "Pruning daily snapshots older than 7 days"
cutoff=$(( $(date +%s) - 7*86400 ))
for d in "$SNAP_ROOT"/*/; do
d_name=$(basename "$d")
# Only YYYY-MM-DD format; skip weekly/monthly subdirs.
[[ $d_name =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || continue
d_epoch=$(iso_to_epoch "${d_name}T00:00:00Z")
if (( d_epoch < cutoff )); then
log_info "Pruning $d_name"
rm -rf -- "$d"
fi
done
# Weekly: every Monday, copy today's snapshot to the weekly bucket.
dow=$(date -u +%u) # 1=Mon..7=Sun
if (( dow == 1 )); then
log_info "Today is Monday — promoting to weekly"
cp -al "$snap_dir" "$SNAP_ROOT/weekly/$today" 2>/dev/null \
|| cp -R "$snap_dir" "$SNAP_ROOT/weekly/$today"
fi
# Monthly: on the 1st, promote to monthly.
dom=$(date -u +%d)
dom_int=$((10#$dom))
if (( dom_int == 1 )); then
log_info "First of month — promoting to monthly"
month=$(date -u +%Y-%m)
cp -al "$snap_dir" "$SNAP_ROOT/monthly/$month" 2>/dev/null \
|| cp -R "$snap_dir" "$SNAP_ROOT/monthly/$month"
fi
log_info "Backup rotation complete"
Cron line:
# /etc/cron.d/backup-rotate
0 2 * * * root /usr/local/bin/backup-rotate >> /var/log/backup.log 2>&1
Notice:
TZ=UTCandLC_ALL=Cset at the top — independent of cron’s environment.- Every timestamp in filenames is UTC ISO date.
- Retention math is in epoch — DST-immune.
10#$domforces base-10 —08and09won’t trip octal parsing.- Date format in filenames sorts correctly by name →
lsshows them in order.
15. Quick reference card
Always-portable formats
date +%s → epoch seconds
date +%Y-%m-%d → 2024-03-10
date -u +%Y-%m-%dT%H:%M:%SZ → 2024-03-10T14:30:00Z
GNU vs BSD cheat sheet
| What | GNU | BSD |
|---|---|---|
| Yesterday | date -d 'yesterday' |
date -v -1d |
| N days ago | date -d 'N days ago' |
date -v -Nd |
| From epoch | date -d '@1234567890' |
date -r 1234567890 |
| Parse string | date -d '2024-03-10 14:00' |
date -j -f '%Y-%m-%d %H:%M' '...' |
| Last Monday | date -d 'last Monday' |
(compute manually) |
| File mtime as epoch | stat -c %Y file |
stat -f %m file |
Always at the top of every cron script
#!/usr/bin/env bash
set -Eeuo pipefail
export TZ=UTC
export LC_ALL=C
The 5 commandments
- UTC for storage, local for display.
- ISO 8601 for everything written to disk or sent over a wire.
- Epoch seconds for arithmetic. Never do
date -dmath when you can do integer math. 10#$xfor any zero-padded number fromdatewhen you’ll do arithmetic on it.- Always set
TZandLC_ALLat the top of cron scripts. Never trust the inherited environment.
16. Wrap-up
Date and time bugs are insidious because they often manifest only on DST days, only in certain locales, or only on the BSD vs GNU half of your fleet. The fixes are:
- Treat ISO 8601 + UTC as the wire protocol of timestamps.
- Wrap GNU/BSD differences in a small
lib/time.sh. - Do all math in epoch seconds.
- Pin
TZ=UTCandLC_ALL=Cat the top of every cron-touched script. - Use
10#$nwhenever you do arithmetic on values fromdatethat can be zero-padded.
Once those habits are in place, your scripts will produce deterministic, sortable, portable timestamps that survive DST and don’t surprise anyone — including future-you reading the logs at 3 AM during an incident.
Next up: L20 — scheduling: cron, systemd timers, anacron, and which to choose. We’ll use the time helpers from this lesson, plus the flock patterns from L16, to build truly idempotent scheduled jobs.