17  Dates

What year was that, what month, and was it a weekday? A date usually arrives as text. to_date reads one, and once a column holds a date there is a word for each part you might want out of it.

The table is diary, three days held as text:

g on_ x
a 2026-01-02 10
a 2026-01-05 20
b 2026-01-06 30
run('
  diary
    then add [d] as to_date([on_])
    then add [year] as year([d]), [month] as month([d]), [weekday] as weekday([d])
')
g on_ x d year month weekday
a 2026-01-02 10 2026-01-02 2026 1 5
a 2026-01-05 20 2026-01-05 2026 1 1
b 2026-01-06 30 2026-01-06 2026 1 2
diary |>
  add(d = to_date(on_)) |>
  add(year = year(d), month = month(d), weekday = weekday(d))
g on_ x d year month weekday
a 2026-01-02 10 2026-01-02 2026 1 5
a 2026-01-05 20 2026-01-05 2026 1 1
b 2026-01-06 30 2026-01-06 2026 1 2
(diary
 >> add(d = to_date(col.on_))
 >> add(year = year(col.d), month = month(col.d), weekday = weekday(col.d)))
g on_ x d year month weekday
a 2026-01-02 10 2026-01-02 2026 1 5
a 2026-01-05 20 2026-01-05 2026 1 1
b 2026-01-06 30 2026-01-06 2026 1 2

diary then add [d] as to_date([on_]) then add [year] as year([d]), [month] as month([d]), [weekday] as weekday([d])

weekday counts Monday as 1, wherever you run it. That is the grammar’s numbering rather than the engine’s, and it has to be: asked plainly, one engine calls a Friday 5 and another calls it 4, and neither says anything is wrong.

17.1 The five parts

year, month, day, weekday and hour. Each one pulls a number out of a date, and each is named for what it returns.

run('
  diary
    then add [on] as to_date([on_])
    then add [y] as year([on]), [m] as month([on]), [d] as day([on]),
         [wd] as weekday([on])
    then pick [on_, y, m, d, wd]
')
on_ y m d wd
2026-01-02 2026 1 2 5
2026-01-05 2026 1 5 1
2026-01-06 2026 1 6 2
diary |>
  add(on = to_date(on_)) |>
  add(y = year(on), m = month(on), d = day(on), wd = weekday(on)) |>
  pick(on_, y, m, d, wd)
on_ y m d wd
2026-01-02 2026 1 2 5
2026-01-05 2026 1 5 1
2026-01-06 2026 1 6 2
(diary
  >> add(on = to_date(col.on_))
  >> add(y = year(col.on), m = month(col.on), d = day(col.on),
         wd = weekday(col.on))
  >> pick(col.on_, col.y, col.m, col.d, col.wd))
on_ y m d wd
2026-01-02 2026 1 2 5
2026-01-05 2026 1 5 1
2026-01-06 2026 1 6 2

hour is the fifth, and it is the one that needs a column carrying a time. The four above read parts of the calendar, which every date has. An hour is not part of the calendar, so a column has to have brought one with it.

stamps did. Its at column arrived as a timestamp rather than as a date:

at
2026-01-02 14:30:00
2026-03-15 09:05:00
run('
  stamps
    then add [h] as hour([at])
    then pick [at, h]
')
at h
2026-01-02 14:30:00 14
2026-03-15 09:05:00 9
stamps |>
  add(h = hour(at)) |>
  pick(at, h)
at h
2026-01-02 14:30:00 14
2026-03-15 09:05:00 9
(stamps
  >> add(h = hour(col.at))
  >> pick(col.at, col.h))
at h
2026-01-02 14:30:00 14
2026-03-15 09:05:00 9

stamps then add [h] as hour([at]) then pick [at, h]

17.2 Why hour will not read a plain date

A column either carries a time or it does not, and the grammar knows which. Ask hour of one that does not and it refuses, rather than hand back a column of zeros that quietly assumed midnight:

collect(stamps |> add(h = hour(to_date(at))))
Error:
! 
illegal: `hour` reads the time of day a column carries, and a plain date carries none, so this would be nought on every row. It wants a column that arrived carrying one, which `to_date` does not make
  |
2 |   then add [h] as hour(to_date([at]))
  |                        ^^^^^^^^^^^^^
try:
    collect(stamps >> add(h = hour(to_date(col.at))))
except GodError as refusal:
    print(refusal)

illegal: `hour` reads the time of day a column carries, and a plain date carries none, so this would be nought on every row. It wants a column that arrived carrying one, which `to_date` does not make
  |
2 |   then add [h] as hour(to_date([at]))
  |                        ^^^^^^^^^^^^^

That refusal is the right shape. The sentence reads as though it should work, the answer would have been a number, and nothing about a column of zeros would have looked wrong on the page. Handing one back is what the grammar will not do.

to_date does not help, and it is the repair everybody reaches for. It makes a date, which is the thing that has no time in it, so the same refusal comes back:

collect(diary |> add(h = hour(to_date(on_))))
Error:
! 
illegal: `hour` reads the time of day a column carries, and a plain date carries none, so this would be nought on every row. It wants a column that arrived carrying one, which `to_date` does not make
  |
2 |   then add [h] as hour(to_date([on_]))
  |                        ^^^^^^^^^^^^^^
try:
    collect(diary >> add(h = hour(to_date(col.on_))))
except GodError as refusal:
    print(refusal)

illegal: `hour` reads the time of day a column carries, and a plain date carries none, so this would be nought on every row. It wants a column that arrived carrying one, which `to_date` does not make
  |
2 |   then add [h] as hour(to_date([on_]))
  |                        ^^^^^^^^^^^^^^

Splitting it over two steps does not help either, and the grammar says so in the same words: what it reads is the column, not the spelling that made it.

So a time has to reach the table as a time. Where the values are text, the conversion belongs to the language you are calling from, above the pipeline: R has as.POSIXct and Python has pd.to_datetime, and that is how at above became a timestamp before any of these examples ran.

The four calendar words are untouched by all of this. A year, a month, a day and a weekday are parts of the calendar, and every date has them whether or not a time came with it.

17.3 Text is not yet a date

The five words read a date, and text is not one until to_date has said so. Skip the conversion and the refusal points straight at it:

collect(diary |> add(y = year(on_)))
Error:
! 
illegal: `year` reads part of a date, and this is text. Convert it first with `to_date(...)`
  |
2 |   then add [y] as year([on_])
  |                         ^^^
try:
    collect(diary >> add(y = year(col.on_)))
except GodError as refusal:
    print(refusal)

illegal: `year` reads part of a date, and this is text. Convert it first with `to_date(...)`
  |
2 |   then add [y] as year([on_])
  |                         ^^^

That is the whole date vocabulary: to_date reads the text, five words take the part you ask for, and the answers agree wherever the sentence runs, Monday counting as 1 on every engine.