Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Bash & the POSIX Shell  Module 11 – Text processing: the Unix toolbox

Dates, times and non-integer numbers

Store timestamps as UTC epoch seconds or ISO 8601 strings, read them from Bash with printf and jq, and compute money as integer paise.

  • Intermediate
  • 20 minutes
  • Examples run with Bash 5.3.20, dash 0.5.13.5, shellcheck 0.11.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Convert a timestamp between epoch seconds and ISO 8601 UTC with GNU date and BSD date
  • Format the time from Bash with printf '%(fmt)T' and measure elapsed time with BASH_MONOSECONDS
  • Explain how a DST change can run a job twice or never, and avoid it by storing UTC
  • Read and write JSON timestamps with jq's fromdateiso8601 and todate
  • Compute totals as integer paise and group digits for an Indian reader

Before you start

What you’ll learn · Before you start

What you will learn

  • Convert a timestamp between epoch seconds and ISO 8601 UTC with GNU date and BSD date
  • Format the time from Bash with printf '%(fmt)T' and measure elapsed time with BASH_MONOSECONDS
  • Explain how a DST change can run a job twice or never, and avoid it by storing UTC
  • Read and write JSON timestamps with jq's fromdateiso8601 and todate
  • Compute totals as integer paise and group digits for an Indian reader

Before you start

On this page

A nightly job on a server in New York is set for 02:30 local time. On the spring-forward morning the wall clock jumps from 02:00 straight to 03:00, so 02:30 never happens; on the fall-back morning every minute from 01:00 to 02:00 happens twice. One UTC timestamp — a number counted since 1970 or an ISO 8601 string with a Z suffix — removes that whole class of bug, because any UTC instant has exactly one wall-clock reading in every zone, not two and not none.

Dates and non-integer numbers both reward one habit: pick an exact storage shape, then format only when a human reads the result. For time the shape is UTC epoch seconds or an ISO 8601 Z string. For money the shape is integer paise. The sections below start with storing a timestamp, move through the Bash features that read one and the time-zone rules that keep it unambiguous, and end with the decimals the shell cannot do and the integers that save a running total from them.

Store a timestamp as UTC epoch seconds or ISO 8601

A UTC epoch seconds: The count of seconds since the Unix epoch, midnight UTC at the start of 1970, as a non-negative integer in POSIX. GNU date -u -d @N converts an epoch to UTC; EPOCHSECONDS is Bash's live read of the same value. count is one integer; an ISO 8601: An international standard for date and time representations; the shape YYYY-MM-DDThh:mm:ssZ spells out year, month, day, hour, minute, second and the zone (Z for UTC), so a reader never has to guess the zone. string with a Z suffix is the same instant written for a human. Both carry every piece of information needed to place the moment in time: the year, the month, the day, the hour, the minute, the second and the zone the fields are read in. A string without a Z or an explicit offset leaves the zone to be guessed, and guessing is where the bugs live. The one habit that survives every move between machines is to UTC: Coordinated Universal Time: a time scale with no daylight saving and no local offset. A UTC instant has one wall-clock reading in every time zone, so storing UTC removes the ambiguity of local-time readings. and format at the edge.

GNU date from GNU coreutils: The GNU project's set of basic Unix text, file and shell utilities (ls, cp, mv, stat, date, sort and so on), under GPL-3.0. It ships by default on most Linux systems; on macOS it is a Homebrew formula that installs them with a g prefix. More in “GNU vs BSD tools: Linux and macOS differences” converts between the two shapes in both directions: date -u -d @<epoch> +'%FT%TZ' prints the ISO string, and date -u -d '<iso>' +'%s' reads it back as the epoch. The -u flag pins the output to UTC regardless of the shell’s own TZ, and %FT%TZ is the strftime format that spells out YYYY-MM-DDTHH:MM:SSZ. The round-trip is lossless because every field is preserved.

Epoch to ISO 8601 and back, with GNU date and BSD date Python · epoch_iso_session.py
"""Epoch seconds and ISO 8601, with GNU and BSD date.

The session shows:
1. A GNU date can print any epoch value as a UTC ISO 8601 string with -u -d @N +'%FT%TZ'.
2. The round-trip ISO -> epoch -> ISO works, and makes 'store UTC, convert at the edge' concrete.
3. On macOS, BSD date (full path /bin/date) spells the same two conversions with -r for 'at this epoch'
   and -ju -f for 'parse this UTC string', so a cross-platform script writes one shape per OS.
4. Adding '+ 1 month' to 2025-01-31 does not land on a Feb date: GNU date's rule normalises Feb 31 forward.
"""
from session import run

run("""
# Convert an epoch to a UTC ISO 8601 string: Z marks UTC, so no local-time ambiguity ever
date -u -d @1760832000 +'%FT%TZ'

# And back: an ISO 8601 string with the Z suffix parses as a UTC instant
date -u -d '2025-10-19T00:00:00Z' +'%s'

# A storage shape worth copying: one JSON line with the UTC timestamp spelt out
printf '{"id":42,"ts":"%s"}\\n' "$(date -u -d @1760832000 +'%FT%TZ')"

# BSD date (macOS) does not accept -d. Full path /bin/date, with -r for 'at this epoch'
/bin/date -ur 1760832000 +'%FT%TZ'

# And -j -f to parse a UTC ISO 8601 string back to an epoch; -u keeps the parse in UTC
/bin/date -juf '%FT%TZ' '2025-10-19T00:00:00Z' +'%s'

# Date arithmetic: GNU date understands 'YYYY-MM-DD + N day' / '... + N month'
date -u -d '2025-02-28 + 1 day' +'%F'

# Trap: 2025 is not a leap year, so '2025-01-31 + 1 month' is Feb 31 normalised to March 3
date -u -d '2025-01-31 + 1 month' +'%F'

# For 'end of next month', build the first of the next month and subtract one day
date -u -d '2025-02-01 - 1 day' +'%F'

# A UTC day's start and end in epoch seconds, ready for a BETWEEN clause
printf 'day starts  = %s\\n' "$(date -u -d '2025-10-19 00:00:00 UTC' +'%s')"
printf 'day ends at = %s\\n' "$(date -u -d '2025-10-20 00:00:00 UTC - 1 second' +'%s')"
""")

Output

# Convert an epoch to a UTC ISO 8601 string: Z marks UTC, so no local-time ambiguity ever
$ date -u -d @1760832000 +'%FT%TZ'
2025-10-19T00:00:00Z
# And back: an ISO 8601 string with the Z suffix parses as a UTC instant
$ date -u -d '2025-10-19T00:00:00Z' +'%s'
1760832000
# A storage shape worth copying: one JSON line with the UTC timestamp spelt out
$ printf '{"id":42,"ts":"%s"}\n' "$(date -u -d @1760832000 +'%FT%TZ')"
{"id":42,"ts":"2025-10-19T00:00:00Z"}
# BSD date (macOS) does not accept -d. Full path /bin/date, with -r for 'at this epoch'
$ /bin/date -ur 1760832000 +'%FT%TZ'
2025-10-19T00:00:00Z
# And -j -f to parse a UTC ISO 8601 string back to an epoch; -u keeps the parse in UTC
$ /bin/date -juf '%FT%TZ' '2025-10-19T00:00:00Z' +'%s'
1760832000
# Date arithmetic: GNU date understands 'YYYY-MM-DD + N day' / '... + N month'
$ date -u -d '2025-02-28 + 1 day' +'%F'
2025-03-01
# Trap: 2025 is not a leap year, so '2025-01-31 + 1 month' is Feb 31 normalised to March 3
$ date -u -d '2025-01-31 + 1 month' +'%F'
2025-03-03
# For 'end of next month', build the first of the next month and subtract one day
$ date -u -d '2025-02-01 - 1 day' +'%F'
2025-01-31
# A UTC day's start and end in epoch seconds, ready for a BETWEEN clause
$ printf 'day starts  = %s\n' "$(date -u -d '2025-10-19 00:00:00 UTC' +'%s')"
day starts  = 1760832000
$ printf 'day ends at = %s\n' "$(date -u -d '2025-10-20 00:00:00 UTC - 1 second' +'%s')"
day ends at = 1760918399

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 epoch_iso_session.py

The session’s date -u -d @1760832000 +'%FT%TZ' printed 2025-10-19T00:00:00Z, and the reverse call read 1760832000 back from that string. The one-line JSON spelt the same timestamp as the ts field, which is the shape most logs and queues use. On macOS, BSD date (full path /bin/date) does not accept -d: the same two conversions use -r for “at this epoch” (/bin/date -ur 1760832000 +'%FT%TZ') and -ju -f for “parse this string” (/bin/date -juf '%FT%TZ' '2025-10-19T00:00:00Z' +'%s'). A script that must run on both machines either branches on uname -s or leaves the conversion to a tool that behaves the same on both, such as the jq date filters later in this lesson.

Interactive · step by step Epoch seconds and ISO 8601, one instant said two ways Watch one timestamp become an epoch, a UTC string, and an epoch again, with no information lost.
Epoch seconds and ISO 8601, one instant said two ways, step 4 of 4. Showing 1760832000 (1760832000 (round-trip)), YYYY-MM-DDThh:mm:ssZ (…T00:00:00Z), date -u -d @N +%FT%TZ, date -u -d STR +%s. In focus: 1760832000, YYYY-MM-DDThh:mm:ssZ.17608320001760832000 (round-trip)YYYY-MM-DDThh:mm:ssZ…T00:00:00Zdate -u -d @N +%FT%TZepoch to ISOdate -u -d STR +%sISO to epoch

The drawing shows the whole scene; its 4 steps are listed below.

Every step as text
  1. One moment in time. 1760832000 is a count of seconds since the Unix epoch, midnight UTC at the start of 1970, and names exactly one instant on the UTC clock. The number is the storage shape every log line, database row and queue message can keep.
  2. Format for a human. date -u -d @1760832000 +'%FT%TZ' takes the epoch, holds it in UTC (the -u flag), and prints the ISO 8601 string the session recorded, which ends in T00:00:00Z: midnight UTC. The Z is part of the format; it says the string is UTC and no time zone has to be guessed.
  3. Parse the string back. date -u -d STR +'%s' reads the Z-marked string and prints 1760832000, the same epoch the round-trip began with. GNU date accepts the ISO shape because -d speaks ISO 8601, so no custom format string is needed on this side.
  4. BSD date, same two steps. macOS ships BSD date, which takes -r for "at this epoch" and -ju -f for "parse this string". The round-trip is /bin/date -ur 1760832000 +'%FT%TZ' and /bin/date -juf '%FT%TZ' STR +'%s'; the ISO string and the epoch are identical, because the format carries all the information.

GNU date -d also does arithmetic on dates with phrases such as '<date> + 1 day' or '<date> + 1 month', and this is where a classic trap lives: adding a month to the last day of January in a common year does not land on the last day of February. The session’s run of date -u -d '2025-01-31 + 1 month' +'%F' printed 2025-03-03, because GNU date adds one to the month field and then normalises: the 31st day of a 28-day February rolls three days into March. The safe idiom for the last day of a month builds the first day of the month after it and subtracts a day: date -u -d '2025-02-01 - 1 day' +'%F' returned 2025-01-31.

Key idea

Store the UTC epoch or an ISO 8601 Z string; convert in and out with date -u -d @N +'%FT%TZ' and date -u -d STR +'%s', and remember the month-end rule when you add a month.

Checkpoint

Check: Epoch, ISO 8601 and UTC

  1. Question 1 of 2 Which GNU date command prints the ISO 8601 UTC string for the epoch 1760832000?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: date -u -d @1760832000 +'%FT%TZ'

    The epoch_iso session's recorded run printed the Z-marked UTC string from the first command. -d accepts @N for an epoch and ISO strings as input; -u is the piece that keeps the output in UTC.

  2. Question 2 of 2 What does date -u -d '2025-01-31 + 1 month' +'%F' print, and why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 2025-03-03, because GNU date adds one to the month field and then normalises the impossible February date forward

    The month-end trap bit the recorded run. The safe form subtracts one day from the first day of the month that follows, and the session's run of it printed the last day of January cleanly.

Since Bash 4.2, printf has a %(fmt)T conversion that calls strftime directly, so a tight loop that formats a timestamp needs no external date process. The argument -1 means “now”, -2 means the shell’s start time, and any epoch N is formatted at N. The format string uses the standard strftime placeholders: %F for the date, %T for the time, %Z for the time zone name, %s for the epoch itself. The feature is a Bash extension; dash and POSIX printf do not have it, which matters when a script runs under #!/bin/sh.

Bash 5.0 added EPOCHSECONDS (the current wall-clock time in seconds) and EPOCHREALTIME (the same number with microsecond granularity). Bash 5.3 added BASH_MONOSECONDS, which reads the system’s monotonic clock in whole seconds. The three variables and the printf conversion are the whole kit for printing time from Bash without starting a process.

printf '%(fmt)T' with 0, a fixed epoch and TZ Python · printf_time_session.py
"""printf '%(fmt)T': format a time from Bash without a fork.

The session shows:
1. Since Bash 4.2, printf has a %(fmt)T conversion that calls strftime; a special argument -1 means 'now',
   -2 means the shell's start time, and any epoch N is formatted at N (bash-NEWS: 'bash-4.2 beta ... new printf
   %(...)T format').
2. The format string uses the standard strftime placeholders: %F for date, %T for time, %Z for the time zone name,
   %H:%M for the clock, %s for the epoch itself.
3. The result is a Bash builtin: it works without forking a date process, which matters inside a tight loop.
4. The time zone of a formatted epoch is the shell's: with TZ=Asia/Kolkata set on the one line, the same epoch
   is written in IST and never in UTC.
"""
from session import run

run("""
# printf '%(fmt)T' expects an epoch argument; epoch 0 is the Unix epoch
printf 'epoch 0 -> %(%F %T %Z)T\\n' 0

# A fixed epoch picks exactly one instant on the clock; 1760832000 is 2025-10-19T00:00:00Z
printf '%(%F %T %Z)T\\n' 1760832000

# The format uses standard strftime placeholders: %F for the date, %T for the time, %Z for the zone name
printf 'date: %(%F)T\\ntime: %(%T)T\\nzone: %(%Z)T\\n' 1760832000 1760832000 1760832000

# TZ on the one line is enough: no need to export, and the shell goes back to UTC afterwards
TZ=Asia/Kolkata printf '%(%F %T %Z)T\\n' 1760832000
printf '%(%F %T %Z)T\\n' 1760832000

# Fields pulled apart cleanly, no external date process in sight
printf '%(year=%Y month=%m day=%d weekday=%A)T\\n' 1760832000

# The epoch itself can be read back from the same conversion with %s
printf '%(%s)T\\n' 1760832000
""")

Output

# printf '%(fmt)T' expects an epoch argument; epoch 0 is the Unix epoch
$ printf 'epoch 0 -> %(%F %T %Z)T\n' 0
epoch 0 -> 1970-01-01 00:00:00 UTC
# A fixed epoch picks exactly one instant on the clock; 1760832000 is 2025-10-19T00:00:00Z
$ printf '%(%F %T %Z)T\n' 1760832000
2025-10-19 00:00:00 UTC
# The format uses standard strftime placeholders: %F for the date, %T for the time, %Z for the zone name
$ printf 'date: %(%F)T\ntime: %(%T)T\nzone: %(%Z)T\n' 1760832000 1760832000 1760832000
date: 2025-10-19
time: 00:00:00
zone: UTC
# TZ on the one line is enough: no need to export, and the shell goes back to UTC afterwards
$ TZ=Asia/Kolkata printf '%(%F %T %Z)T\n' 1760832000
2025-10-19 05:30:00 IST
$ printf '%(%F %T %Z)T\n' 1760832000
2025-10-19 00:00:00 UTC
# Fields pulled apart cleanly, no external date process in sight
$ printf '%(year=%Y month=%m day=%d weekday=%A)T\n' 1760832000
year=2025 month=10 day=19 weekday=Sunday
# The epoch itself can be read back from the same conversion with %s
$ printf '%(%s)T\n' 1760832000
1760832000

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 printf_time_session.py

The session’s printf '%(%F %T %Z)T\n' 0 printed 1970-01-01 00:00:00 UTC — the Unix epoch. The same conversion on 1760832000 wrote 2025-10-19 00:00:00 UTC, and TZ=Asia/Kolkata printf '%(%F %T %Z)T\n' 1760832000 wrote 2025-10-19 05:30:00 IST. The TZ setting lives on the one line, so the shell goes back to its own zone afterwards. %s round-tripped the number through the same conversion, which is why printf is the fast way to pull apart the year, month, day, weekday or clock without calling date.

Timeline · 3 events in order Bash's time features, by release Each release added one way to read or format the time without starting a date process.
  1. 4.2

    printf conversion for strftime builtin

    The %(fmt)T conversion of printf arrived, with -1 for 'now' and any epoch N as its argument. Bash now has a strftime builtin, so a tight loop that formats a time does not fork a date process for each line.

  2. 5.0

    EPOCHSECONDS and EPOCHREALTIME variable

    EPOCHSECONDS is the current wall-clock time in seconds, and EPOCHREALTIME the same with microsecond granularity, as a floating-point string. Both are dynamic variables; each read is a fresh value.

  3. 5.3

    BASH_MONOSECONDS monotonic

    BASH_MONOSECONDS reads the system's monotonic clock. Its differences never go backwards and are not affected by DST or an NTP step, so it is the clock to use for a measured elapsed time.

The timeline has 3 events, from 4.2 to 5.3.

For an elapsed-time measurement, BASH_MONOSECONDS is the clock to reach for: its difference between two reads never goes backwards, even if the wall clock is stepped by NTP or crosses a DST boundary. For sub-second intervals, read EPOCHREALTIME into two variables and let awk take the floating-point difference.

EPOCHSECONDS, EPOCHREALTIME and BASH_MONOSECONDS at work Python · interval_session.py
"""EPOCHSECONDS, EPOCHREALTIME and BASH_MONOSECONDS: measure elapsed time without a fork.

The session shows:
1. EPOCHSECONDS (Bash 5.0+) is the current wall-clock time in seconds; EPOCHREALTIME is the same with
   microsecond granularity, as a floating-point string.
2. BASH_MONOSECONDS (Bash 5.3+) is a monotonic clock in whole seconds, immune to wall-clock jumps and to DST
   changes; its differences never go backwards.
3. A pair of reads with the measured command between them, divided by awk for the sub-second case, is a
   one-liner replacement for 'time cmd' when a script needs the number.

The outputs printed by this session come from the running clock, so they vary every run; the lesson's
<Example expect="regex" pattern={...}> matches each line's shape against a regex.
"""
from session import run

run("""
# EPOCHSECONDS is the current time; it is set by a new read each time, so no fork is needed
echo "$EPOCHSECONDS"

# EPOCHREALTIME has microsecond granularity, as a floating-point seconds-since-epoch string
echo "$EPOCHREALTIME"

# BASH_MONOSECONDS is a monotonic clock, so differences across it are robust to clock changes
echo "$BASH_MONOSECONDS"

# A sleep timed with BASH_MONOSECONDS; the diff is whole seconds and never negative
start=$BASH_MONOSECONDS; sleep 2; end=$BASH_MONOSECONDS; printf 'monotonic seconds elapsed = %d\\n' $((end - start))

# A sub-second measurement with EPOCHREALTIME; let awk take the float difference and format it
start=$EPOCHREALTIME; sleep 0.5; end=$EPOCHREALTIME; awk -v s="$start" -v e="$end" 'BEGIN {printf "float seconds elapsed = %.3f\\n", e - s}'

# A tiny function that times any command without touching stderr
elapsed() { local s=$EPOCHREALTIME; "$@"; local e=$EPOCHREALTIME; awk -v s="$s" -v e="$e" 'BEGIN {printf "took %.3f s\\n", e - s}'; }
elapsed sleep 0.2
""")

Output

# EPOCHSECONDS is the current time; it is set by a new read each time, so no fork is needed
$ echo "$EPOCHSECONDS"
1792364573
# EPOCHREALTIME has microsecond granularity, as a floating-point seconds-since-epoch string
$ echo "$EPOCHREALTIME"
1792364573.667077
# BASH_MONOSECONDS is a monotonic clock, so differences across it are robust to clock changes
$ echo "$BASH_MONOSECONDS"
210889
# A sleep timed with BASH_MONOSECONDS; the diff is whole seconds and never negative
$ start=$BASH_MONOSECONDS; sleep 2; end=$BASH_MONOSECONDS; printf 'monotonic seconds elapsed = %d\n' $((end - start))
monotonic seconds elapsed = 2
# A sub-second measurement with EPOCHREALTIME; let awk take the float difference and format it
$ start=$EPOCHREALTIME; sleep 0.5; end=$EPOCHREALTIME; awk -v s="$start" -v e="$end" 'BEGIN {printf "float seconds elapsed = %.3f\n", e - s}'
float seconds elapsed = 0.513
# A tiny function that times any command without touching stderr
$ elapsed() { local s=$EPOCHREALTIME; "$@"; local e=$EPOCHREALTIME; awk -v s="$s" -v e="$e" 'BEGIN {printf "took %.3f s\n", e - s}'; }
$ elapsed sleep 0.2
took 0.349 s

This output changes from run to run: EPOCHSECONDS, EPOCHREALTIME and the sleep timings read the running clock, so the numeric values change on every run; the lines, variables and formats do not

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 interval_session.py

The session’s start=$BASH_MONOSECONDS; sleep 2; end=$BASH_MONOSECONDS with a printf of the difference produced monotonic seconds elapsed = 2 on the recorded run; the number may be 2 or 3 depending on where the two reads land on a one-second boundary and on the shell’s own overhead, which is why the pattern above accepts any whole number of seconds. The sub-second block used EPOCHREALTIME and awk to print three decimals. The small elapsed function wraps any command and prints its timing without touching standard error, which time would.

EPOCHSECONDS instead of date +%s, except where the fork is desired

Reaching for date +%s inside a tight loop forks a process on every iteration; the same loop with $EPOCHSECONDS runs the whole loop in the Bash process alone. date +%s is still right when the shell is dash or POSIX sh (no EPOCHSECONDS), or when the script must read the clock through the same code path that formats it.

Checkpoint

Check: Bash time features

  1. Question 1 of 2 What does the Bash builtin printf '%(%F %T %Z)T\n' 1760832000 print, in a shell with TZ=UTC?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 2025-10-19 00:00:00 UTC

    The printf_time session's recorded run printed this line from the same conversion. TZ on the one line is enough to switch the zone: the IST run printed 05:30:00 IST from the same epoch.

  2. Question 2 of 2 Which clock would you read into start and end to time a two-second sleep safely across a DST change or an NTP step?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: BASH_MONOSECONDS, because it reads a monotonic clock whose difference never goes backwards

    BASH_MONOSECONDS is a monotonic clock variable: its difference across two reads cannot go backwards. For sub-second intervals, use EPOCHREALTIME (also wall-clock) and accept that a clock step can appear in the number.

Convert at the edge: time zones and DST

A time zone: A mapping from a UTC instant to a local wall-clock reading, with a history of switches held in the tz database (zoneinfo on Linux and macOS). TZ=Asia/Kolkata or TZ=America/New_York selects which mapping GNU date reads. is a mapping from a UTC instant to a local wall-clock reading, with a history of switches that go back decades. GNU date reads the mapping from the system’s tz database (/usr/share/zoneinfo on Linux, the same path on macOS), so TZ=Asia/Kolkata date -d @1760832000 and TZ=America/New_York date -d @1760832000 show the same instant from two zones. The instant is unchanged; only the wall-clock reading moves.

Asia/Kolkata is a fixed offset of +05:30 and never observes daylight saving time: A clock adjustment where some zones advance the local wall clock by an hour in spring and reverse it in autumn. The spring step makes one local hour missing; the autumn step repeats one local hour on the wall clock.. Every date, every year, maps one Indian local time to exactly one UTC instant and back. America/New_York observes DST, so twice a year the wall clock has either a missing hour or a repeated hour, and the local-time to UTC map is not one-to-one in that hour.

Asia/Kolkata, America/New_York and the DST traps Python · timezones_session.py
"""Time zones and DST: the same UTC instant seen from India and from New York.

The session shows:
1. TZ changes how a UTC epoch is printed; the instant does not move.
2. Asia/Kolkata is a fixed offset (UTC+05:30) with no daylight saving, so Indian times never jump.
3. America/New_York springs forward on the second Sunday of March at 02:00: the local time 02:30 does not
   exist, and GNU date refuses to parse it. One hour later, 03:30 is EDT (-04:00).
4. On a fall-back day (first Sunday of November), the local wall clock crosses 01:30 twice; a UTC timestamp
   has one unambiguous reading, which is why 'store UTC, convert at the edge' is the rule.
"""
from session import run

run("""
# One UTC epoch, four different wall clocks: the instant does not move
TZ=UTC             date -d @1760832000 +'%F %T %Z %z'
TZ=Asia/Kolkata    date -d @1760832000 +'%F %T %Z %z'
TZ=America/New_York date -d @1760832000 +'%F %T %Z %z'
TZ=Europe/London   date -d @1760832000 +'%F %T %Z %z'

# Asia/Kolkata is a fixed offset: no DST, so a local time maps to one UTC instant and back
TZ=Asia/Kolkata date -d '2025-10-19 05:30:00' +'%s %z'
TZ=UTC         date -d @1760832000 +'%F %T %Z'

# Spring forward: on 2025-03-09 America/New_York skips 02:00 to 03:00; 01:30 is EST
TZ=America/New_York date -d '2025-03-09 01:30' +'%F %T %Z %z'

# 02:30 on that day never happened on a wall clock; GNU date rejects it
!TZ=America/New_York date -d '2025-03-09 02:30' +'%F %T %Z %z'

# 03:30 is EDT: two hours after 01:30 on the wall clock, but only one hour after it in real time
TZ=America/New_York date -d '2025-03-09 03:30' +'%F %T %Z %z'

# Fall back: on 2025-11-02 the clock rolls 02:00 back to 01:00; 01:30 happens twice
# One UTC epoch is unambiguous, so store UTC and convert at the edge
first=$(date -u -d '2025-11-02 05:30:00 UTC' +'%s')
second=$(date -u -d '2025-11-02 06:30:00 UTC' +'%s')
printf 'first  UTC epoch = %s\\nsecond UTC epoch = %s (one hour later)\\n' "$first" "$second"
TZ=America/New_York date -d "@$first"  +'first  New York wall clock -> %F %T %Z %z'
TZ=America/New_York date -d "@$second" +'second New York wall clock -> %F %T %Z %z'

# A one-liner that writes a user-facing timestamp from the stored UTC epoch
echo '{"ts":"2025-10-19T00:00:00Z"}' | awk -F'"' '/ts/{print $4}' | xargs -I{} env TZ=Asia/Kolkata date -d {} +'%F %H:%M %Z'
""")

Output

# One UTC epoch, four different wall clocks: the instant does not move
$ TZ=UTC             date -d @1760832000 +'%F %T %Z %z'
2025-10-19 00:00:00 UTC +0000
$ TZ=Asia/Kolkata    date -d @1760832000 +'%F %T %Z %z'
2025-10-19 05:30:00 IST +0530
$ TZ=America/New_York date -d @1760832000 +'%F %T %Z %z'
2025-10-18 20:00:00 EDT -0400
$ TZ=Europe/London   date -d @1760832000 +'%F %T %Z %z'
2025-10-19 01:00:00 BST +0100
# Asia/Kolkata is a fixed offset: no DST, so a local time maps to one UTC instant and back
$ TZ=Asia/Kolkata date -d '2025-10-19 05:30:00' +'%s %z'
1760832000 +0530
$ TZ=UTC         date -d @1760832000 +'%F %T %Z'
2025-10-19 00:00:00 UTC
# Spring forward: on 2025-03-09 America/New_York skips 02:00 to 03:00; 01:30 is EST
$ TZ=America/New_York date -d '2025-03-09 01:30' +'%F %T %Z %z'
2025-03-09 01:30:00 EST -0500
# 02:30 on that day never happened on a wall clock; GNU date rejects it
$ TZ=America/New_York date -d '2025-03-09 02:30' +'%F %T %Z %z'
date: invalid date ‘2025-03-09 02:30’
[exit status 1]
# 03:30 is EDT: two hours after 01:30 on the wall clock, but only one hour after it in real time
$ TZ=America/New_York date -d '2025-03-09 03:30' +'%F %T %Z %z'
2025-03-09 03:30:00 EDT -0400
# Fall back: on 2025-11-02 the clock rolls 02:00 back to 01:00; 01:30 happens twice
# One UTC epoch is unambiguous, so store UTC and convert at the edge
$ first=$(date -u -d '2025-11-02 05:30:00 UTC' +'%s')
$ second=$(date -u -d '2025-11-02 06:30:00 UTC' +'%s')
$ printf 'first  UTC epoch = %s\nsecond UTC epoch = %s (one hour later)\n' "$first" "$second"
first  UTC epoch = 1762061400
second UTC epoch = 1762065000 (one hour later)
$ TZ=America/New_York date -d "@$first"  +'first  New York wall clock -> %F %T %Z %z'
first  New York wall clock -> 2025-11-02 01:30:00 EDT -0400
$ TZ=America/New_York date -d "@$second" +'second New York wall clock -> %F %T %Z %z'
second New York wall clock -> 2025-11-02 01:30:00 EST -0500
# A one-liner that writes a user-facing timestamp from the stored UTC epoch
$ echo '{"ts":"2025-10-19T00:00:00Z"}' | awk -F'"' '/ts/{print $4}' | xargs -I{} env TZ=Asia/Kolkata date -d {} +'%F %H:%M %Z'
2025-10-19 05:30 IST

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 timezones_session.py

The spring-forward side of the trap is in the session: TZ=America/New_York date -d '2025-03-09 02:30' printed date: invalid date ‘2025-03-09 02:30’ and exited with status 1, because 02:30 never happened on that day: the clock jumped from 02:00 EST straight to 03:00 EDT, and GNU date refuses to invent a local time. The line above it ('2025-03-09 01:30') parsed as 01:30 EST; the line below ('2025-03-09 03:30') parsed as 03:30 EDT. What a scheduler does with a 02:30 entry on that day is its own rule: cronie: the cron daemon (crond) of the cronie project, built on Paul Vixie's cron; each minute it checks every crontab entry and starts the jobs due in that minute. runs a fixed-time job from a skipped hour right after the jump, and avoids running one twice when the clock goes back.

Interactive · step by step Fall back in New York, one wall-clock reading from two UTC instants Watch two different UTC epochs print the same New York wall clock on the fall-back day.
Fall back in New York, one wall-clock reading from two UTC instants, step 5 of 5. Showing 05:30:00Z (1762061400 (unambiguous)), 06:30:00Z (1762065000 (unambiguous)), 01:30 EDT (01:30 EDT (first)), 01:30 EST (01:30 EST (second)), clock rolls back. In focus: 05:30:00Z, 06:30:00Z.TZ=America/New_YorkTZ=America/New_Yorkbeforeafter05:30:00Z1762061400 (unambiguous)06:30:00Z1762065000 (unambiguous)01:30 EDT01:30 EDT (first)01:30 EST01:30 EST (second)clock rolls backat local 02:00 EDT

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. Two different UTC instants. One UTC epoch is 1762061400 (05:30 UTC) and another is 1762065000 (06:30 UTC), one hour apart on the same day. Each names exactly one moment in time; neither is ambiguous.
  2. Before the switch. The first UTC instant lands at 01:30 EDT in New York, with offset -04:00. Daylight saving time is still on, so the local clock reads 01:30 as the first of two readings that day.
  3. The clock rolls back. At local 02:00 EDT, the clock rolls back to 01:00 EST. The next real hour of time plays out again on the wall clock, so every minute between 01:00 and 02:00 happens for a second time.
  4. After the switch. The second UTC instant lands at 01:30 EST, offset -05:00. The wall clock reads 01:30 again, an hour later in UTC. A log line that stored only '01:30' in local time cannot say which run of the hour it belongs to.
  5. Store UTC, convert at the edge. The two UTC epochs tell them apart cleanly: 1762061400 is the first 01:30 and 1762065000 is the second. The rule 'store UTC, convert at the edge' avoids the ambiguity before it starts, which is why every log line and database row keeps the epoch or the Z-marked UTC string.

The fall-back side of the trap is in the next lines of the session: two different UTC epochs (1762061400 and 1762065000), one hour apart, both land at 01:30 on the New York wall clock. The first is 01:30 EDT (-0400, before the switch), the second is 01:30 EST (-0500, after the switch). A log line that stored "ts":"2025-11-02 01:30:00" without a zone cannot say which hour it belongs to; a line that stored the UTC epoch or the Z-marked string can. The rule “store UTC, convert at the edge” exists exactly to keep the ambiguous local reading out of storage.

Scheduling on local time in a DST zone

Wrong: setting a server’s time zone to a DST zone and assuming every scheduler starts a job in the skipped or the repeated hour exactly once. cronie compensates for clock changes of under three hours, but a job runner that matches the local wall clock itself can miss a 02:30 run in spring and start a 01:30 run twice in autumn. Right: schedule in UTC (TZ=UTC for the service, or CRON_TZ=UTC in a cronie crontab) and have the job convert for the user. A job that must follow a wall clock picks a time outside the change hours (04:30 in New York) or runs on an interval from a monotonic start.

Checkpoint

Check: Time zones and DST

  1. Question 1 of 2 Why does TZ=America/New_York date -d '2025-03-09 02:30' print an error and exit 1 on GNU date?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The local time 02:30 never happens on that day: the clock jumps from 02:00 EST to 03:00 EDT at the spring-forward boundary

    The timezones session recorded invalid date with exit status 1 on exactly that line. 01:30 EST and 03:30 EDT parsed cleanly: two hours apart on the wall clock, one hour apart in real time.

  2. Question 2 of 2 A New York server's log line stored only 2025-11-02 01:30:00, with no zone. How many UTC instants could it name?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Two: one before the fall-back switch (01:30 EDT, -0400) and one after (01:30 EST, -0500)

    The timezones session built two UTC epochs one hour apart that both land at 01:30 on the New York wall clock: this is why 'store UTC, convert at the edge' is the rule.

Read and write JSON dates with jq

JSON has no date type, so a timestamp lives there as a string (an ISO 8601 Z string, if the writer followed the rule) or as an epoch number. jq’s date filters bridge the two shapes: fromdateiso8601 reads an ISO string and returns the epoch, and todate reads an epoch and returns the ISO string. Both work in UTC and leave the surrounding shape of the JSON alone. The pair sits well with the pipelines of jq: query and reshape JSON from the shell.

fromdateiso8601, todate and strftime over a JSON array Python · jq_dates_session.py
"""jq dates: fromdateiso8601 and todate for a JSON round-trip in UTC.

The session shows:
1. jq's fromdateiso8601 reads an ISO 8601 string (YYYY-MM-DDTHH:MM:SSZ) and gives the epoch as a number.
2. todate (an alias of todateiso8601) reads an epoch and gives the ISO 8601 string in UTC with the Z suffix
   (jq's manual: these builtins "deal exclusively with time in UTC").
3. The two together turn a UTC-stored JSON array into one that is still UTC-stored but sorted or filtered.
4. jq's . | strftime("%F %T %Z") is the way to format for a human reader when the output is text.
5. fromdateiso8601 reads exactly the shape %Y-%m-%dT%H:%M:%SZ: a string with an offset such as +05:30 stops jq.
"""
from session import run, write

write("events.json", '''{
  "events": [
    { "id": 1, "ts": "2025-11-02T06:30:00Z", "amount_paise": 24999 },
    { "id": 2, "ts": "2025-11-02T05:30:00Z", "amount_paise": 59900 },
    { "id": 3, "ts": "2025-11-02T07:30:00Z", "amount_paise": 10000 }
  ]
}
''')

run("""
# fromdateiso8601 parses an ISO 8601 UTC timestamp into an epoch number
jq -n '"2025-10-19T00:00:00Z" | fromdateiso8601'

# todate turns an epoch back into an ISO 8601 UTC string (with the Z suffix)
jq -n '1760832000 | todate'

# The same instant written with a +05:30 offset is not the shape fromdateiso8601 reads
!jq -n '"2025-10-19T05:30:00+05:30" | fromdateiso8601'

# A round-trip on a whole array: sort by the UTC instant each event names
jq '.events | sort_by(.ts | fromdateiso8601)' events.json

# Keep the events from 05:30 up to, not including, 06:00 UTC on that day
lo=$(date -u -d '2025-11-02 05:30:00 UTC' +'%s'); hi=$(date -u -d '2025-11-02 06:00:00 UTC' +'%s'); jq --argjson lo "$lo" --argjson hi "$hi" '.events | map(select((.ts | fromdateiso8601) >= $lo and (.ts | fromdateiso8601) < $hi))' events.json

# strftime formats an epoch for a human; pair it with the stored UTC epoch to show local time
jq -r '.events[] | "\\(.id)\\t\\(.ts | fromdateiso8601 | strftime("%F %T %Z"))"' events.json

# Add a human-friendly field without losing the UTC source of truth
jq '.events | map(. + {display: (.ts | fromdateiso8601 | strftime("%F %H:%M UTC"))})' events.json
""")

Output

# fromdateiso8601 parses an ISO 8601 UTC timestamp into an epoch number
$ jq -n '"2025-10-19T00:00:00Z" | fromdateiso8601'
1760832000
# todate turns an epoch back into an ISO 8601 UTC string (with the Z suffix)
$ jq -n '1760832000 | todate'
"2025-10-19T00:00:00Z"
# The same instant written with a +05:30 offset is not the shape fromdateiso8601 reads
$ jq -n '"2025-10-19T05:30:00+05:30" | fromdateiso8601'
jq: error (at <unknown>): date "2025-10-19T05:30:00+05:30" does not match format "%Y-%m-%dT%H:%M:%SZ"
[exit status 5]
# A round-trip on a whole array: sort by the UTC instant each event names
$ jq '.events | sort_by(.ts | fromdateiso8601)' events.json
[
  {
    "id": 2,
    "ts": "2025-11-02T05:30:00Z",
    "amount_paise": 59900
  },
  {
    "id": 1,
    "ts": "2025-11-02T06:30:00Z",
    "amount_paise": 24999
  },
  {
    "id": 3,
    "ts": "2025-11-02T07:30:00Z",
    "amount_paise": 10000
  }
]
# Keep the events from 05:30 up to, not including, 06:00 UTC on that day
$ lo=$(date -u -d '2025-11-02 05:30:00 UTC' +'%s'); hi=$(date -u -d '2025-11-02 06:00:00 UTC' +'%s'); jq --argjson lo "$lo" --argjson hi "$hi" '.events | map(select((.ts | fromdateiso8601) >= $lo and (.ts | fromdateiso8601) < $hi))' events.json
[
  {
    "id": 2,
    "ts": "2025-11-02T05:30:00Z",
    "amount_paise": 59900
  }
]
# strftime formats an epoch for a human; pair it with the stored UTC epoch to show local time
$ jq -r '.events[] | "\(.id)\t\(.ts | fromdateiso8601 | strftime("%F %T %Z"))"' events.json
1	2025-11-02 06:30:00 UTC
2	2025-11-02 05:30:00 UTC
3	2025-11-02 07:30:00 UTC
# Add a human-friendly field without losing the UTC source of truth
$ jq '.events | map(. + {display: (.ts | fromdateiso8601 | strftime("%F %H:%M UTC"))})' events.json
[
  {
    "id": 1,
    "ts": "2025-11-02T06:30:00Z",
    "amount_paise": 24999,
    "display": "2025-11-02 06:30 UTC"
  },
  {
    "id": 2,
    "ts": "2025-11-02T05:30:00Z",
    "amount_paise": 59900,
    "display": "2025-11-02 05:30 UTC"
  },
  {
    "id": 3,
    "ts": "2025-11-02T07:30:00Z",
    "amount_paise": 10000,
    "display": "2025-11-02 07:30 UTC"
  }
]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 jq_dates_session.py

The session’s jq -n '"2025-10-19T00:00:00Z" | fromdateiso8601' printed 1760832000, and the reverse jq -n '1760832000 | todate' returned the string with the Z suffix. The same instant written with a +05:30 offset stopped jq with does not match format "%Y-%m-%dT%H:%M:%SZ": the filter reads that one shape, so a feed that carries offsets goes through GNU date -u -d STR +'%FT%TZ' first. On the sample events.json, the pipeline .events | sort_by(.ts | fromdateiso8601) ordered the array by the UTC instant each event names, without touching the stored string. The range filter used two --argjson arguments to pass UTC epochs computed by date, which turns jq into a query engine that reads a human window and acts on the unambiguous numeric field.

Interactive · stage by stage Store UTC, convert at the edge Follow one event from write to display; the stored string stays UTC, the display changes.
Store UTC, convert at the edge, step 5 of 5. Showing Write (…T05:30:00Z), Store (ts: …T05:30:00Z), Query (epoch >= $lo AND < $hi), Convert (TZ=Asia/Kolkata or UTC), Show (11:00 IST). In focus: Show.Write…T05:30:00ZStorets: …T05:30:00ZQueryepoch >= $lo AND < $hiConvertTZ=Asia/Kolkata or UTCShow11:00 IST

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. Write the UTC string. A producer takes the current moment, formats it as an ISO 8601 string that ends in T05:30:00Z and writes the row. The Z suffix is the whole promise: no reader has to guess which zone this was.
  2. Store the string as it is. The event lives as JSON (or as a timestamptz column with a UTC default). Nothing adds a local offset, and nothing strips the Z. Every row of the day looks the same.
  3. Query by UTC range. Reads use the stored string's epoch (jq's fromdateiso8601) and ask for a UTC window: from 1762061400 up to, not including, 1762063200 keeps the events from 05:30 to 06:00 UTC. No DST rule is needed to pick a window.
  4. Convert for one reader. At the edge, strftime formats the stored epoch in the reader's time zone. A reader in Pune sees 11:00 IST; a reader in New York sees 01:30 EDT. The row has not moved; only the display changed.
  5. Show in local time. The reader's page shows a human-friendly 11:00 IST, with the day as the reader's own calendar names it. If the row ever moves to another database or another country, the UTC string in storage still means the same instant. The edge is the only place local time appears.

For a human-friendly display, jq’s strftime formats an epoch with the same placeholders Bash’s printf does. The session’s jq -r '.events[] | "\(.id)\t\(.ts | fromdateiso8601 | strftime("%F %T %Z"))"' produced one line per event with the UTC timestamp spelt out in words, and the .events | map(. + {display: ...}) filter added a display field without replacing the stored one. The stored string stayed Z-marked; only the derived field moved into a reader’s clock.

Feeding fromdateiso8601 a timestamp in another shape

Wrong: passing fromdateiso8601 a timestamp with an offset (+05:30), with fractional seconds (.250Z) or with a space where the T goes; jq 1.8.2 stops on each with does not match format. Right: normalise to %Y-%m-%dT%H:%M:%SZ before jq reads the field. The names are not the trap: todate is an alias of todateiso8601, and the manual says fromdate currently reads only ISO 8601 strings too.

Checkpoint

Check: jq date filters

  1. Question 1 of 2 Which jq filter turns the string "2025-10-19T00:00:00Z" into the epoch 1760832000?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: fromdateiso8601

    jq's high-level date builtins are fromdateiso8601, todateiso8601 (alias todate), fromdate and now; strptime, mktime, gmtime and strftime are the low-level interfaces. The recorded jq_dates session called fromdateiso8601 and todate on their own lines.

  2. Question 2 of 2 Which jq filter sorts the events in events.json by their UTC instant?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: .events | sort_by(.ts | fromdateiso8601)

    The jq_dates session recorded the sorted array with events in ts order (05:30, 06:30, 07:30 Z strings). Keep the source of truth as the UTC epoch; let display derive from it with strftime.

Non-integer numbers and money as integer paise

Bash’s $(( )) and ((...)) arithmetic is integer-only: $((10/3)) is 3, and $((10.5/3)) is a syntax error, not a silent truncation. For a decimal number the shell reaches outside itself to awk or bc (see Shell arithmetic). awk handles arithmetic as a double-precision float and formats with printf '%.*f'; bc keeps every digit at a chosen scale and the -l flag loads the math library and sets a default scale of 20.

Both reach the same floating-point reality: IEEE 754 doubles do not store every short decimal exactly, and the rounding of printf '%.2f' depends on which way the real double lies, not on the decimal that was typed.

awk, bc and the IEEE 754 rounding traps Python · decimals_session.py
"""Decimals from the shell: awk, bc and the integer-minor-unit rule for money.

The session shows:
1. Bash's $(( )) and ((...)) are integer-only: 10/3 is 3, and a decimal literal is a syntax error.
2. awk and bc both handle decimals; awk uses printf '%.*f' to format and bc needs scale and -l for math
   functions. GNU awk 5.4.1 (gawk) is called via `awk` on the lesson's toolchain; bc here is Mac bc 7.0.3,
   which prints exact decimals and prefixes a bare fractional result with a leading dot (.3, not 0.3).
3. IEEE 754 double-precision floats bite the classic cases: 0.1 + 0.2 is 0.30000000000000004, and the
   decimal 2.675 does not fit exactly in a double, so printf '%.2f' rounds to 2.67; 2.685 rounds to 2.69
   for the same reason, the other way. The double representation decides.
4. The money rule is to count in paise (integer minor units) and format at the end: no float arithmetic,
   no surprise rounding.
"""
from session import run, write

write("basket.csv", """id,item,paise
1,Milk 1L,5500
2,Rice 5kg,42000
3,Oil 1L,21499
4,Pulses 1kg,18750
""")

run("""
# Shell $(( )) is integer-only; a decimal literal is a syntax error, not a silent truncation
echo "integer division: $((10/3))"
!echo "decimal literal: $((10.5/3))"

# awk reads decimals; its printf's %.*f formats to the chosen number of places
awk 'BEGIN {printf "%.2f\\n", 10/3}'
awk 'BEGIN {printf "%.4f\\n", 1.1 + 2.2}'

# IEEE 754 trap: 0.1 + 0.2 is not exactly 0.3; the closest double is a shade too big
awk 'BEGIN {printf "%.17f\\n", 0.1 + 0.2}'

# 2.675 and 2.685 round the opposite way with %.2f: the double of 2.675 is just below, of 2.685 just above
awk 'BEGIN {printf "%.2f\\n", 2.675}'
awk 'BEGIN {printf "%.2f\\n", 2.685}'

# bc keeps every digit at a chosen scale; scale is 0 by default, so 10/3 is 3 without it
echo 'scale=4; 10/3' | bc
echo '10/3' | bc

# bc -l enables the math library and sets scale=20 by default
echo 'sqrt(2)' | bc -l

# bc's own decimal arithmetic makes 0.1 + 0.2 exactly .3, printed without a leading zero
echo 'scale=2; 0.1 + 0.2' | bc

# The money rule: store amounts in paise (integer rupees times 100), add in integer arithmetic
total=$(awk -F, 'NR>1 {t += $3} END {print t+0}' basket.csv)
echo "total paise = $total"

# Format for a human at the very end, dividing by 100 only once, with awk's %.2f
echo "$total" | awk '{printf "rupees = %.2f\\n", $1 / 100}'

# A 5 % tax on each line, kept in paise with integer rounding (nearest, ties go up); summed in integers
awk -F, 'NR>1 { tax = int($3 * 5 / 100 + 0.5); grand += $3 + tax; printf "line %d: %s = %d + %d paise\\n", $1, $2, $3, tax } END { printf "grand total = %d paise = %.2f rupees\\n", grand, grand/100 }' basket.csv
""")

Output

# Shell $(( )) is integer-only; a decimal literal is a syntax error, not a silent truncation
$ echo "integer division: $((10/3))"
integer division: 3
$ echo "decimal literal: $((10.5/3))"
bash: 10.5/3: arithmetic syntax error: invalid arithmetic operator (error token is ".5/3")
[exit status 1]
# awk reads decimals; its printf's %.*f formats to the chosen number of places
$ awk 'BEGIN {printf "%.2f\n", 10/3}'
3.33
$ awk 'BEGIN {printf "%.4f\n", 1.1 + 2.2}'
3.3000
# IEEE 754 trap: 0.1 + 0.2 is not exactly 0.3; the closest double is a shade too big
$ awk 'BEGIN {printf "%.17f\n", 0.1 + 0.2}'
0.30000000000000004
# 2.675 and 2.685 round the opposite way with %.2f: the double of 2.675 is just below, of 2.685 just above
$ awk 'BEGIN {printf "%.2f\n", 2.675}'
2.67
$ awk 'BEGIN {printf "%.2f\n", 2.685}'
2.69
# bc keeps every digit at a chosen scale; scale is 0 by default, so 10/3 is 3 without it
$ echo 'scale=4; 10/3' | bc
3.3333
$ echo '10/3' | bc
3
# bc -l enables the math library and sets scale=20 by default
$ echo 'sqrt(2)' | bc -l
1.41421356237309504880
# bc's own decimal arithmetic makes 0.1 + 0.2 exactly .3, printed without a leading zero
$ echo 'scale=2; 0.1 + 0.2' | bc
.3
# The money rule: store amounts in paise (integer rupees times 100), add in integer arithmetic
$ total=$(awk -F, 'NR>1 {t += $3} END {print t+0}' basket.csv)
$ echo "total paise = $total"
total paise = 87749
# Format for a human at the very end, dividing by 100 only once, with awk's %.2f
$ echo "$total" | awk '{printf "rupees = %.2f\n", $1 / 100}'
rupees = 877.49
# A 5 % tax on each line, kept in paise with integer rounding (nearest, ties go up); summed in integers
$ awk -F, 'NR>1 { tax = int($3 * 5 / 100 + 0.5); grand += $3 + tax; printf "line %d: %s = %d + %d paise\n", $1, $2, $3, tax } END { printf "grand total = %d paise = %.2f rupees\n", grand, grand/100 }' basket.csv
line 1: Milk 1L = 5500 + 275 paise
line 2: Rice 5kg = 42000 + 2100 paise
line 3: Oil 1L = 21499 + 1075 paise
line 4: Pulses 1kg = 18750 + 938 paise
grand total = 92137 paise = 921.37 rupees

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 decimals_session.py

The session’s awk 'BEGIN {printf "%.17f\n", 0.1 + 0.2}' returned 0.30000000000000004: 0.1 is not exactly representable, nor is 0.2, and their sum sits a shade above 0.3. awk 'BEGIN {printf "%.2f\n", 2.675}' rounded to 2.67 (the stored double is just below the decimal 2.675), while awk 'BEGIN {printf "%.2f\n", 2.685}' rounded to 2.69 (its double is just above). The direction depends on the number, not on the format string. bc keeps its own decimal arithmetic and dodges those traps: echo 'scale=2; 0.1 + 0.2' | bc printed .3, exact but with no leading zero, so a pipeline that reads bc’s output into awk or a Bash variable has to agree on scale and allow for the missing 0.

Interactive · step by step Keep money as integer paise, format rupees at the end Watch the basket's total build in paise with no float arithmetic, then become rupees for the user.
Keep money as integer paise, format rupees at the end, step 4 of 4. Showing basket.csv (5500 42000 21499 18750), awk accumulator (t = 87,749), + 5 % tax (nearest paise), 92,137 (92,137 paise), Rs 921.37 (Rs 921.37). In focus: 92,137, write, Rs 921.37, fmt.basket.csv5500 42000 21499 18750awk accumulatort = 87,749+ 5 % taxnearest paise92,13792,137 paiseRs 921.37Rs 921.37

The drawing shows the whole scene; its 4 steps are listed below.

Every step as text
  1. Each line is already in paise. basket.csv keeps every amount as an integer: Milk 5500, Rice 42000, Oil 21499, Pulses 18750. No decimals are stored anywhere, so no column has to round, and awk reads each field as a whole number.
  2. Add in integers. The accumulator t runs through every line and holds the running sum. After the four lines, t is 87749 paise, the plain sum of the paise column. awk's integer arithmetic is exact for values up to about 2^53.
  3. Add the tax, still as paise. Line tax = int($3 * 5 / 100 + 0.5) rounds nearest (ties up) and stays in paise. Rows get +275, +2100, +1075 and +938 paise, grouped into grand with grand += $3 + tax. The grand ends at 92,137 paise.
  4. One division at the end. Only now does the number leave integer land: awk prints %.2f with grand/100, which gives Rs 921.37. The divide-at-the-end rule has one float operation in the whole flow, so there is nowhere a 2.675 trap could bite the running total.

The rule that keeps money correct is to store amounts as integer minor unit (paise): The smallest countable unit of a currency, so one rupee is 100 paise. Keeping amounts as integer paise lets a shell add them exactly; the divide by 100 happens once, at the format step for a human. and divide by 100 only when a human reads the total. The session’s basket.csv keeps every amount in paise; the awk -F, accumulator sums the integers and prints the grand total both ways (grand total = 92137 paise = 921.37 rupees). A 5 % line tax rounded by int(p * 5 / 100 + 0.5) stays in paise too, so the running total never meets a floating-point value until the format step at the end.

Indian digit grouping with %'d and the LC_ALL trap

Numbers in the Indian reading group as 2, 2, 2, 3 from the right (12,34,567 for 1234567), so a crore reads as 1,00,00,000. In an en_IN.UTF-8 locale, printf "%'d\n" and numfmt --grouping both apply the grouping. LC_ALL in the environment overrides every other LC_ variable (the POSIX precedence), so LC_NUMERIC=en_IN.UTF-8 on its own has no effect inside a shell that already exports LC_ALL=C.UTF-8: clear LC_ALL for the one command with env -u LC_ALL LC_NUMERIC=en_IN.UTF-8 cmd, or write LC_ALL=en_IN.UTF-8 cmd directly. Check that the locale exists first with locale -a | grep -i en_IN: a stripped-down server image may not include it.

Indian digit grouping with printf, numfmt and awk Python · indian_grouping_session.py
"""Indian digit grouping: printf '%'d', LC_NUMERIC and numfmt --grouping.

The session shows:
1. The apostrophe flag in a printf conversion asks the C library to apply the locale's digit grouping.
   LC_ALL in the environment overrides every other LC_ variable (the POSIX locale precedence), so
   LC_NUMERIC=en_IN.UTF-8 on its own has no effect in a shell that already exports LC_ALL=C.UTF-8.
2. Clearing LC_ALL for one command (env -u LC_ALL) and setting LC_NUMERIC=en_IN.UTF-8 writes digits in
   the Indian lakh/crore reading: groups of 2, 2, 2, 3 from the right, 12,34,567.
3. LC_ALL=en_IN.UTF-8 is the shorter one-liner; both forms produce the same output.
4. GNU coreutils' numfmt --grouping reads a number from standard input and groups it with the same
   locale rules; it matches printf.
5. The locale name en_IN.UTF-8 may not exist on a stripped-down server; run `locale -a | grep -i en_IN`
   before you rely on it.
"""
from session import run

run("""
# The shell is started with LC_ALL=C.UTF-8, which is why LC_NUMERIC alone does nothing here
LC_NUMERIC=en_IN.UTF-8 printf "%'d\\n" 1234567

# Unset LC_ALL for the one command, then LC_NUMERIC decides the grouping (en_IN: 12,34,567)
env -u LC_ALL LC_NUMERIC=en_IN.UTF-8 printf "%'d\\n" 1234567
env -u LC_ALL LC_NUMERIC=en_IN.UTF-8 printf "%'d\\n" 10000000

# LC_ALL on the command is the shorter spelling and overrides C.UTF-8 for that command only
LC_ALL=en_IN.UTF-8 printf "%'d\\n" 1234567

# The C locale uses no grouping: a plain run of digits
LC_ALL=C printf "%'d\\n" 1234567

# en_US.UTF-8 groups in threes (1,234,567)
LC_ALL=en_US.UTF-8 printf "%'d\\n" 1234567

# numfmt --grouping reads from standard input and uses the same locale
LC_ALL=en_IN.UTF-8 numfmt --grouping <<< 1234567
LC_ALL=en_US.UTF-8 numfmt --grouping <<< 1234567
LC_ALL=C          numfmt --grouping <<< 1234567

# The locale may not be installed on a server; this check names it one way or the other
locale -a | grep -q '^en_IN.UTF-8$' && echo 'en_IN.UTF-8 is available on this computer' || echo 'en_IN.UTF-8 is NOT available on this computer'

# A paisa-to-rupees formatter that groups Indian-style and keeps two decimals
format_rupees() { LC_ALL=en_IN.UTF-8 awk -v p="$1" 'BEGIN {printf "%\\x27.2f\\n", p / 100}'; }
format_rupees 92137
format_rupees 15000000
""")

Output

# The shell is started with LC_ALL=C.UTF-8, which is why LC_NUMERIC alone does nothing here
$ LC_NUMERIC=en_IN.UTF-8 printf "%'d\n" 1234567
1234567
# Unset LC_ALL for the one command, then LC_NUMERIC decides the grouping (en_IN: 12,34,567)
$ env -u LC_ALL LC_NUMERIC=en_IN.UTF-8 printf "%'d\n" 1234567
12,34,567
$ env -u LC_ALL LC_NUMERIC=en_IN.UTF-8 printf "%'d\n" 10000000
1,00,00,000
# LC_ALL on the command is the shorter spelling and overrides C.UTF-8 for that command only
$ LC_ALL=en_IN.UTF-8 printf "%'d\n" 1234567
12,34,567
# The C locale uses no grouping: a plain run of digits
$ LC_ALL=C printf "%'d\n" 1234567
1234567
# en_US.UTF-8 groups in threes (1,234,567)
$ LC_ALL=en_US.UTF-8 printf "%'d\n" 1234567
1,234,567
# numfmt --grouping reads from standard input and uses the same locale
$ LC_ALL=en_IN.UTF-8 numfmt --grouping <<< 1234567
12,34,567
$ LC_ALL=en_US.UTF-8 numfmt --grouping <<< 1234567
1,234,567
$ LC_ALL=C          numfmt --grouping <<< 1234567
1234567
# The locale may not be installed on a server; this check names it one way or the other
$ locale -a | grep -q '^en_IN.UTF-8$' && echo 'en_IN.UTF-8 is available on this computer' || echo 'en_IN.UTF-8 is NOT available on this computer'
en_IN.UTF-8 is available on this computer
# A paisa-to-rupees formatter that groups Indian-style and keeps two decimals
$ format_rupees() { LC_ALL=en_IN.UTF-8 awk -v p="$1" 'BEGIN {printf "%\x27.2f\n", p / 100}'; }
$ format_rupees 92137
921.37
$ format_rupees 15000000
1,50,000.00

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 indian_grouping_session.py

The session’s env -u LC_ALL LC_NUMERIC=en_IN.UTF-8 printf "%'d\n" 1234567 printed 12,34,567, and the same locale through numfmt --grouping gave the same string. In the C locale the grouping is turned off and the number is a flat 1234567; in en_US.UTF-8 the groups are of three digits (1,234,567). The format_rupees function at the bottom of the session is a one-liner that joins the paise rule and the Indian grouping: an integer argument, awk -v p=... printf "%'.*f", and a divide-by-100 at the format step. 15000000 paise came out as 1,50,000.00 rupees — a rupee amount a reader in India can read at a glance.

Checkpoint

Check: Decimals and paise

  1. Question 1 of 2 In Bash's $(( )) arithmetic, what happens when the expression contains a decimal literal, as in $((10.5/3))?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A syntax error, because shell arithmetic is integer-only

    The decimals session recorded 'bash: 10.5/3: arithmetic syntax error' with exit status 1. For decimals, awk and bc do the work; the shell integer world adds in paise for money.

  2. Question 2 of 2 A basket totals 92137 paise. Which one-liner formats this as rupees with two decimals, grouped for an Indian reader?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: LC_ALL=en_IN.UTF-8 awk -v p=92137 'BEGIN {printf "%\x27.2f\n", p/100}'

    The decimals session's grand total printed 921.37 rupees from 92137 paise, and the indian_grouping session's format_rupees wrote 921.37 and 1,50,000.00 from 92137 and 15000000 paise with this awk format. Keep the pipeline: integer paise, one divide, one format.

Practise: timestamps, zones, decimals

Try these at your own terminal, from the lesson’s folder. Each one is one or two shell lines, and the recorded sessions have every piece you need.

  1. Write a one-line pipeline that prints the ISO 8601 UTC string for the Unix epoch second 1_800_000_000. Hint: date -u -d @N +'%FT%TZ'.
  2. Compute the elapsed time in seconds between two ISO 8601 UTC timestamps $START and $END, formatted as HH:MM:SS. Hint: date -u -d on each, subtract to seconds, then printf '%(%T)T\n' $diff if diff is under a day.
  3. Write a jq filter that keeps only the events in events.json whose ts lies in the UTC window from 2025-11-02T05:30:00Z (inclusive) to 2025-11-02T07:00:00Z (exclusive).
  4. Format an integer paise value 12345678 as rupees for an Indian reader. Hint: LC_ALL=en_IN.UTF-8 awk -v p=12345678 'BEGIN { printf "Rs %'"'"'.2f\n", p / 100 }'.
  5. Explain in two sentences why a job runner that matches the local wall clock can miss a 02:30 run on the spring-forward day and start a 01:30 run twice on the fall-back day, and what one change fixes both. Hint: the server’s time zone and the job’s time zone are two different settings.

The two interview questions below are not about the shapes above; they are about the engineering choice to store UTC and keep money in paise. Work out the first move of each one before you open the explanation:

Practice that adapts to you

Practise this lesson

5 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 5 A system runs across two offices in Pune and New York, and stores timestamps as local time. Which is the strongest reason to switch storage to UTC?

    Choose one answer.

    Show the answer: Question 1 of 5

    Answer: A local-time field is ambiguous once a day in a DST zone, so two different UTC instants land on the same stored value

    The DST fall-back repeats one hour of local time; storing UTC removes the repeat before it reaches a reader. All the tools in this lesson (jq, awk, GNU date, BSD date) treat UTC as the lingua franca.

  2. Question 2 of 5 A build script measures each step's elapsed time with EPOCHSECONDS, and a nightly run sometimes reports -3600 for one step. What is the most likely cause?

    Choose one answer.

    Show the answer: Question 2 of 5

    Answer: NTP stepped the clock during the step, by one hour or so

    BASH_MONOSECONDS reads a monotonic clock, so its difference cannot go backwards. Swap EPOCHSECONDS for BASH_MONOSECONDS in the step timer, and the measured interval stays positive even when NTP steps the clock.

  3. Question 3 of 5 A JSON event has ts: "2025-10-19T00:00:00Z". A reader in Pune should see what wall-clock reading on their screen?

    Choose one answer.

    Show the answer: Question 3 of 5

    Answer: 2025-10-19 05:30 IST

    The timezones session recorded this exact conversion. Store UTC, format at the reader's edge with strftime and the right TZ; the stored string stays the same regardless of who reads it.

  4. Question 4 of 5 Which jq filter formats .ts (an ISO 8601 UTC string) into 2025-11-02 05:30 UTC without changing .ts in storage?

    Choose one answer.

    Show the answer: Question 4 of 5

    Answer: .ts | fromdateiso8601 | strftime("%F %H:%M UTC")

    Keep the stored field unchanged; derive the display with a filter on the right-hand side of a pipe. The jq_dates session added a display field in exactly this shape, so the UTC source of truth stayed intact.

  5. Question 5 of 5 A script needs the average of three values 1.5, 2.5 and 3.5 and uses s=$((1.5 + 2.5 + 3.5)); echo $((s/3)). The script fails. What is the fix with the fewest changes?

    Choose one answer.

    Show the answer: Question 5 of 5

    Answer: Replace both $((...)) with one awk call: awk 'BEGIN {print (1.5 + 2.5 + 3.5) / 3}'

    awk is the "do a sum of decimals, format and move on" tool. For money, the integer-paise rule applies instead. The decimals session showed both paths: awk for a one-off average, integer paise for a running total.

Interview questions

Warm-up: why store a timestamp as UTC epoch seconds (fresher to 2–5 years)?

A UTC epoch is one integer; it names exactly one instant in time, and the mapping to the wall clock of any zone is a pure function of the tz database the reader uses. A local-time string without a zone, by contrast, leaves the reader to guess which zone was meant, and a local-time string with a zone still goes through a DST-aware mapping that is not one-to-one for one hour in spring and one hour in autumn. Storing UTC avoids both: the field is unambiguous, every tool that reads it (jq, awk, GNU date, BSD date) agrees on the instant it names, and converting for a human is one strftime call at the edge. The lesson’s recorded jq_dates_session.py sorted and filtered a JSON array by its UTC ts; the stored string stayed Z-marked and only the derived display field moved into a reader’s clock. The same shape works for logs, queues, database columns and schedulers, so a service built on it stays consistent across machines, regions and hosting moves.

The two deeper questions below are about a cron run on a DST change, and a total that was wrong by a paisa. Work out each one before you open the first step:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A crontab entry runs a billing summary at 02:30, and the server's zone is America/New_York. Before the spring and autumn clock changes, ops ask whether the summary can be skipped or run twice. What does cronie do on each day, which local times are at risk, and what change removes the question?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to mid

    A nightly invoice job sums 20,000 subscription lines, each 24.99 rupees, with awk 'BEGIN { for (i=0; i<20000; i++) t += 24.99 } END { printf "%.2f", t }' and prints 499,799.99. The product owner expects 499,800.00. What happened, and what one change in the pipeline fixes it?

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. Which two shapes store a timestamp without ambiguity?

    UTC epoch seconds, one integer, and an ISO 8601 string in UTC ending in Z. A local time without a zone leaves the reader guessing which zone was meant.

  2. How does GNU date convert between epoch seconds and an ISO 8601 UTC string?

    date -u -d @N +'%FT%TZ' turns an epoch into the string, and date -u -d STR +'%s' reads the string back as an epoch. BSD date on macOS does the same with -r and -ju -f.

  3. How can Bash print the time without starting date?

    printf '%(fmt)T' formats a time inside Bash. EPOCHSECONDS and EPOCHREALTIME give the wall clock, and Bash 5.3's BASH_MONOSECONDS a monotonic clock that NTP steps and DST changes do not move.

  4. Why is a local wall-clock time not always one instant?

    In a zone with daylight saving time, one local hour is missing in spring and one is repeated in autumn, so a local reading can mean two UTC instants or none. Store UTC and convert at the edge.

  5. How does jq convert between ISO 8601 strings and epoch numbers?

    fromdateiso8601 turns a UTC string into an epoch and todate turns it back; strftime formats an epoch for a person and leaves the stored field alone.

  6. How should a shell script add up rupee amounts exactly?

    Store them as integer paise, add in integers, and divide by 100 only when a person reads the total. Other decimals go through awk or bc, because Bash arithmetic is integer only.

Key takeaways

  • A UTC epoch (an integer since 1970) and an ISO 8601 Z string are the two shapes that store a timestamp unambiguously; date -u -d @N +'%FT%TZ' and date -u -d STR +'%s' convert in both directions, and BSD date on macOS does the same two steps with -r and -ju -f.
  • printf '%(fmt)T' formats a time from Bash with no fork; EPOCHSECONDS and EPOCHREALTIME give the wall clock, and BASH_MONOSECONDS gives a monotonic clock that is immune to NTP steps and DST changes.
  • A DST zone has a missing hour in spring and a repeated hour in autumn; store UTC and convert at the edge, because any UTC instant has one wall-clock reading in every zone but a local reading can mean two UTC instants or none.
  • jq’s fromdateiso8601 and todate round-trip between an ISO 8601 UTC string and an epoch number; strftime formats an epoch for a human and leaves the stored field alone.
  • Bash arithmetic is integer-only, so decimals go through awk or bc; store money as integer paise, add in integers and divide by 100 only when a human reads the total; group an Indian number with LC_ALL=en_IN.UTF-8 and printf "%'d".

Terms in this lesson

daylight saving time
A clock adjustment where some zones advance the local wall clock by an hour in spring and reverse it in autumn. The spring step makes one local hour missing; the autumn step repeats one local hour on the wall clock.
epoch seconds
The count of seconds since the Unix epoch, midnight UTC at the start of 1970, as a non-negative integer in POSIX. GNU date -u -d @N converts an epoch to UTC; EPOCHSECONDS is Bash's live read of the same value.
ISO 8601
An international standard for date and time representations; the shape YYYY-MM-DDThh:mm:ssZ spells out year, month, day, hour, minute, second and the zone (Z for UTC), so a reader never has to guess the zone.
minor unit (paise)
The smallest countable unit of a currency, so one rupee is 100 paise. Keeping amounts as integer paise lets a shell add them exactly; the divide by 100 happens once, at the format step for a human.
time zone
A mapping from a UTC instant to a local wall-clock reading, with a history of switches held in the tz database (zoneinfo on Linux and macOS). TZ=Asia/Kolkata or TZ=America/New_York selects which mapping GNU date reads.
UTC
Coordinated Universal Time: a time scale with no daylight saving and no local offset. A UTC instant has one wall-clock reading in every time zone, so storing UTC removes the ambiguity of local-time readings.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.