1. 0 0 31 * * は年に 7 回しか動かない
crontab に「毎月 31 日」と書く人の多くは、「月末」のつもりで書いている。cron は 31 日が無い月には何も言わずに何もしない。同じ動きを Kairos で書いて、数えてみる:
premise JP { calendar-system: Gregorian; tz: "Asia/Tokyo"; wkst: Mon }
@JP
everyDay |> within(month) |> nth(31)
$ kairos list --from 2026-01-01 --to 2027-01-01 day31.kairos 2026-01-31 2026-03-31 2026-05-31 2026-07-31 2026-08-31 2026-10-31 2026-12-31
年 7 回。2・4・6・9・11 月は、何も起きない。「月末」が欲しかったなら、書くべきは last である:
everyDay |> within(month) |> last
こちらは 12 回(1/31, 2/28, 3/31, 4/30, …, 12/31)。式が「各月の最後の日」と読み下せるので、月の長さは窓が吸収する。
ここまでは cron の有名な落とし穴で、cron からの引っ越しでも書いた。この記事の主題はその一歩先——30 日のように、たいていの月にはあって、2 月だけ無い日をどう扱うか、である。
2. 「毎月 30 日払い」には二つの読みがある
「支払いは毎月 30 日」という契約は珍しくない。2 月は 28 日(うるう年は 29 日)までしかない。このとき、書き手の意図は二つに分かれる:
- 飛ばす: 2 月は支払日が無い(3 月 30 日にまとめて、など運用で吸収)
- 月末に寄せる: 2 月は 28 日に払う
どちらも実務にある。困るのは、どちらを意図したかが式に現れないことだ。iCalendar の RRULE でBYMONTHDAY=30 と書くと、規格(RFC 5545)は無い月を黙って飛ばす。「月末に寄せる」を書くための SKIP=BACKWARD は後から規格化された(RFC 7529)が、主要な実装は11 年経っても未対応である。既定の挙動へ黙って倒れる、というのが事故の型で、cron も RRULE も同じ場所で黙る。
Kairos では、二つの読みが別の式になる。飛ばす形は §1 と同じ:
everyDay |> within(month) |> nth(30)
$ kairos list --from 2026-01-01 --to 2027-01-01 day30-skip.kairos 2026-01-30 2026-03-30 2026-04-30 (…) 2026-12-30
11 回。2 月が無い。式が「30 日がある月だけ」と読めるので、これは黙りではなく正当な空である。
月末に寄せる形は、「30 日」と「30 日が無い月の月末」の和で書く:
day30 = everyDay |> within(month) |> nth(30) short = (everyDay |> within(month) |> last) |> filter(d => daysInMonthOf(d) < 30) day30 | short
$ kairos list --from 2026-01-01 --to 2027-01-01 day30-roll.kairos 2026-01-30 2026-02-28 2026-03-30 2026-04-30 (…) 2026-12-30
12 回。2 月だけ 28 日になった。初めて出る記法を二つ。daysInMonthOf(d) は「点 d が属する月の日数」を返す(2 月なら 28 か 29)。| は和で、二つの列を合わせる。short は「月末のうち、その月が 30 日より短いもの」なので、2026 年では 2 月 28 日だけが残る。
大事なのは、式を読めばどちらの意図か分かることである。飛ばす式には nth(30) しかなく、寄せる式には short がある。半年後に読み返した人が、規格の既定を思い出す必要はない。
3. 休日なら翌営業日
支払期日は「土日祝なら翌営業日」が付き物なので、営業日の定義を前文に足して、最後にroll(Following, on: bizDay)(休みなら次の営業日へ寄せる。roll と shift の違いは第 19 弾):
premise JP {
calendar-system: Gregorian; tz: "Asia/Tokyo"; wkst: Mon
national = [2026-01-01, 2026-01-12, 2026-02-11, 2026-02-23, 2026-03-20, 2026-04-29, 2026-05-03..2026-05-06,
2026-07-20, 2026-08-11, 2026-09-21..2026-09-23, 2026-10-12, 2026-11-03, 2026-11-23] covering: 2026..2026
satSun = everyDay |> filter(d => weekday(d) == Sat or weekday(d) == Sun)
bizDay = everyDay \ (satSun | national)
}
@JP
day30 = everyDay |> within(month) |> nth(30)
short = (everyDay |> within(month) |> last) |> filter(d => daysInMonthOf(d) < 30)
(day30 | short) |> roll(Following, on: bizDay)
$ kairos list --from 2026-01-01 --to 2027-01-01 payday30.kairos 2026-01-30 2026-03-02 2026-03-30 2026-04-30 2026-06-01 2026-06-30 2026-07-30 2026-08-31 2026-09-30 2026-10-30 2026-11-30 2026-12-30 # ⚠ 範囲外 2026-12-31..2027-01-01(JP.national covering 2026-01-01..2026-12-31)
2 月 28 日(土)は 3 月 2 日(月)へ、5 月 30 日(土)は 6 月 1 日(月)へ、8 月 30 日(日)は8 月 31 日(月)へ寄った。「無い月は月末」と「休日は翌営業日」が、二段のパイプで順に効いている。
末尾の注記は、12 月 30 日が休みだったら翌営業日は 2027 年に落ちるが、祝日表は 2026 年までしか無い、という言語からの一言である(この定義では 12/30 は水曜なので実害はない)。読み方は前々回の 25 日払いの例に書いた。
4. 「無い日」は他にもある
- 2 月 29 日。「うるう年の 2 月 29 日にだけ動く」なら
nth(29)を 2 月に限って使えばよく、 「2 月末」ならlast。飛ばすか寄せるかは、ここでも式が違う。 - 31 日。「月末」のつもりなら
last。nth(31)は「31 日がある月だけ」であって、月末ではない。 - 給料日 25 日は無い月が無いので、二義は生じない。生じるのは 29・30・31 日だけである。
書き手が「無い月をどうするか」を決めていないことが、事故の本当の原因である。cron や RRULE は決めていない人の代わりに既定で決めてしまう。Kairos は決めるまで書けない——nth(30) と書いた時点で「飛ばす」と決めたことになり、寄せたいなら short を足す。
5. 動かす
月末最終営業日の自動実行まで通すの型 1 がそのまま使える。crontab は「毎日 9 時」、動くかどうかの判定は定義に委ねる:
0 9 * * * cd /srv/billing && ./run-if-today.sh payday30.kairos ./issue-invoice.sh
まとめ
0 0 31 * *は年 7 回。「月末」はlastで 12 回- 「毎月 30 日」には飛ばすと月末に寄せるの二義があり、cron も RRULE も既定へ黙って倒れる
- Kairos では別の式になる: 飛ばす=
nth(30)、寄せる=day30 | short(daysInMonthOfで短い月を拾う) - 休日の繰り下げは最後に
roll(Following, on: bizDay)——二段のパイプが順に効く - 事故の原因は「無い月をどうするか」を決めていないこと。式は決めるまで書けない
▶ Playground で定義を実行する——2026 年の支払日 12 点が出る。コメントにある nth(31)(年 7 回)・last(月末)・day30 | short(休日を見ない)の行と入れ替えて、二義の違いを確かめられる。ブラウザ内で完結し、何も送信しない。
Kairos はスケジュール定義言語です。仕様と参照実装はGitHub で公開しています。

コメント