Shell Lesson 19 of 42

Date & Time Arithmetic: ISO 8601, Time Zones, GNU vs BSD `date` & Cron-Safe Math — Stop Letting Timestamps Eat Your Scripts

If you’ve written shell for more than a year, you’ve been bitten by date handling. The classic failures:

This lesson is a complete, defensive treatment of date handling in shell:

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:

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:

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:

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:

  1. TZ environment variable (if set, wins).
  2. /etc/localtime symlink (on most Linuxes).
  3. 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:

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:


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:

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

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:

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:

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:


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

  1. UTC for storage, local for display.
  2. ISO 8601 for everything written to disk or sent over a wire.
  3. Epoch seconds for arithmetic. Never do date -d math when you can do integer math.
  4. 10#$x for any zero-padded number from date when you’ll do arithmetic on it.
  5. Always set TZ and LC_ALL at 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:

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.

shellbashdatetimeiso8601timezonecronportabilitymacoslinux
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments