ISO week dates in Emacs Lisp and Bash

Relative dates, and time, and calendars have all been on my mind since last month.

First I published FromNow (a Bash package that outputs time difference from now in different formats) and Awhile (an Emacs package that does the same plus a few extra tricks). Then I wrote about both in Relative dates, to your taste, where I showed examples and explained some of my choices.

After that I calculated weekdays for Christmas using some 30 variations of Emacs Lisp and Bash — “dozens of ways to reach the same result” being a recurrent theme of this site, so why not?

Later, I shared some snippets about dealing with ordinal dates.

And then I published HyCal, a Hybrid ISO week–Gregorian calendar with color themes.

So with this last topic fresh on my mind, let’s have a look at how to do some ISO week–related things in Emacs Lisp and Bash.

Find the number of weeks in an ISO year

Gregorian Dec 28 is always in the last ISO week of the year.

Most ISO week years (82.25%) have exactly 52 weeks (364 days), whereas the other 17.75% have exactly 53 weeks (371 days).

Bash

flandrew-num-of-iso-weeks()
{ : "$(date -d "$1" +%G)"; date -d "$_-12-28" +%V ;}

flandrew-num-of-iso-weeks 2042-04-02  #⇒ 52
flandrew-num-of-iso-weeks 2043-04-02  #⇒ 53

Emacs Lisp

With dash.el and ts.el:

(defun flandrew-num-of-iso-weeks (when)
  "Given a date WHEN, return number of weeks in the ISO year."
  (--> (ts-parse when)
       (ts-format "%G" it) string-to-number
       (ts-apply :year it :month 12 :day 28 (ts-now))
       (ts-format "%V" it) string-to-number))

or with just dash:

(defun flandrew-num-of-iso-weeks (when)
  "Given a date WHEN, return number of weeks in the ISO year."
  (->> when
       date-to-time (format-time-string "%G") (format "%s-12-28")
       date-to-time (format-time-string "%V") string-to-number))

To use only native functions, either replace ->> above with thread-last, or:

(defun flandrew-num-of-iso-weeks (when)
  "Given a date WHEN, return number of weeks in the ISO year."
  (let* ((year (format-time-string "%G" (date-to-time when)))
         (last (format "%s-12-28" year))
         (time (date-to-time last)))
    (string-to-number (format-time-string "%V" time))))

Any of which work:

(flandrew-num-of-iso-weeks "2042-04-02") => 52
(flandrew-num-of-iso-weeks "2043-04-02") => 53

Convert from Gregorian to ISO week

Straightforward in both languages.

Bash

flandrew-iso-week-from-gregorian() { date +"%G-W%V-%u" -d "$1" ;}

flandrew-iso-week-from-gregorian 2042-04-02  #⇒ 2042-W14-3
flandrew-iso-week-from-gregorian 2043-04-02  #⇒ 2043-W14-4

Emacs Lisp

(defun flandrew-iso-week-from-gregorian (when)
  "Convert WHEN from Gregorian to ISO week."
  (format-time-string "%G-W%V-%u" (date-to-time when)))
(flandrew-iso-week-from-gregorian "2042-04-02") => "2042-W14-3"
(flandrew-iso-week-from-gregorian "2043-04-02") => "2043-W14-4"

Match an ISO week date with a regular expression

Bash

flandrew-match-isoweek()
[[ "$1" =~ ^([0-9]{4})-W([0-5][0-9])-([1-7])$ ]]

flandrew-show-isoweek-matches() {
    flandrew-match-isoweek "$1"
    printf "%s\n" "${BASH_REMATCH[@]}" ;}

flandrew-show-isoweek-matches 2042-W14-3
2042-W14-3
2042
14
3

Emacs Lisp

(defun flandrew-match-isoweek (when)
  "Match ISO week date WHEN."
  (string-match (rx (: bos  (group (= 4 num))
                       "-W" (group (in "0-5") (in num))
                       "-"  (group (in "1-7")) eos))
                when))

(defun flandrew-show-isoweek-matches (when)
  "Show ISO week date matches."
  (when (flandrew-match-isoweek when)
    (mapcar (lambda (n) (match-string n when))
            (list 0 1 2 3))))
(flandrew-show-isoweek-matches "2042-W14-3")
=> '("2042-W14-3" "2042" "14" "3")

Convert from ISO week to Gregorian

Not as straightforward as the opposite direction.

Gregorian Jan 04 is always in the first ISO week of the year.

Bash

flandrew-match-isoweek()
[[ "$1" =~ ^([0-9]{4})-W([0-5][0-9])-([1-7])$ ]]

flandrew-iso-week-to-gregorian()
if flandrew-match-isoweek "$1"
then declare G V u f x
     read -r G V u <<< "${BASH_REMATCH[@]:1:3}"
     if ((V>53))
     then echo >&2 "Invalid ISO week date: $1"; return 1
     fi;  f="$G-01-04"; x="$(date +%u -d "$f")"
     ((_= 7*V - 7 + u - x))
     date -I -d "$f $_ days"
else echo >&2 "Invalid ISO week date: $1"; return 2
fi

flandrew-iso-week-to-gregorian 2042-W14-3
flandrew-iso-week-to-gregorian 2043-W14-4
2042-04-02
2043-04-02

This can be made a bit simpler by merging the ifs and error messages:

flandrew-match-isoweek()
[[ "$1" =~ ^([0-9]{4})-W([0-5][0-9])-([1-7])$ ]]

flandrew-iso-week-to-gregorian() {
    flandrew-match-isoweek "$1"
    declare G V u f x
    read -r G V u <<< "${BASH_REMATCH[@]:1:3}"
    if [[ -z "$V" ]] || ((V>53))
    then echo >&2 "Invalid ISO week date: $1"; return 1
    fi;  f="$G-01-04"; x="$(date +%u -d "$f")"
    ((_= 7*V - 7 + u - x))
    date -I -d "$f $_ days" ;}

flandrew-iso-week-to-gregorian 2042-W14-3
flandrew-iso-week-to-gregorian 2043-W14-4
2042-04-02
2043-04-02

Emacs Lisp

For this, we’ll reuse the previous matching functions:

(defun flandrew-match-isoweek (when)
  "Match ISO week date WHEN."
  (string-match (rx (: bos  (group (= 4 num))
                       "-W" (group (in "0-5") (in num))
                       "-"  (group (in "1-7")) eos))
                when))

(defun flandrew-show-isoweek-matches (when)
  "Show ISO week date matches."
  (when (flandrew-match-isoweek when)
    (mapcar (lambda (n) (match-string n when))
            (list 0 1 2 3))))

And we’ll throw dash and ts at it to make the conversion simpler:

(defun flandrew-iso-week-to-gregorian (when)
  "Convert WHEN from ISO week to Gregorian."
  (-let [(G V u) (-map #'string-to-number
                       (cdr (flandrew-show-isoweek-matches when)))]
    (if (or (null V) (> V 53))
        (error "Invalid ISO week date: %s" when)
      (let* ((f (format "%s-01-04" G))      ; weekday of Jan 04⤵
             (x (string-to-number (ts-format "%u" (ts-parse f))))
             (Δ (-> (* 7 V) (- 7) (+ u) (- x))))
        (->> (ts-parse f)
             (ts-adjust 'day Δ)
             (ts-format "%F"))))))

Let’s test it:

(flandrew-iso-week-to-gregorian "2042-W14-3") => "2042-04-02"
(flandrew-iso-week-to-gregorian "2043-W14-4") => "2043-04-02"

But wait! The elisp solution can be much simplified by using iso8601.el.

(require 'iso8601)

Instead of the three functions above, just this:

(defun flandrew-iso-week-to-gregorian (when)
  "Convert WHEN from ISO week to Gregorian."
  (-let [(_ _ _ d m Y) (iso8601-parse-date when)]
    (format "%04d-%02d-%02d" Y m d)))

No dash? Pick a native alternative — either seq:

(defun flandrew-iso-week-to-gregorian (when)
  "Convert WHEN from ISO week to Gregorian."
  (seq-let (_ _ _ d m Y) (iso8601-parse-date when)
    (format "%04d-%02d-%02d" Y m d)))

or the longer pcase:

(defun flandrew-iso-week-to-gregorian (when)
  "Convert WHEN from ISO week to Gregorian."
  (pcase-let ((`(_ _ _ ,d ,m ,Y) (iso8601-parse-date when)))
    (format "%04d-%02d-%02d" Y m d)))

All of which work:

(flandrew-iso-week-to-gregorian "2042-W14-3") => "2042-04-02"
(flandrew-iso-week-to-gregorian "2043-W14-4") => "2043-04-02"

Enjoy your ISO weekend!

📆 2026-W04-5📆 2026-01-23