- データベース
- 設計
繰り返し予定のDB設計|RRULEと例外日のテーブル例とSQL
繰り返し予定のDB設計は、「親予定に繰り返しの規則(RRULE)と開始・終了を1行で持ち、規則から外れる回だけを例外テーブルに持つ」のが基本です。 毎週の定例を1年分の行にして持つ必要はなく、「この回だけ時間を変える」「この回は休み」は、元の開始日時をキーにした例外の行で表します。
この記事では、CREATE TABLE と ER図、「この回だけ」「これ以降すべて」「すべて」の3種類の変更で各テーブルがどう変わるかを、SQL と実行結果で並べます。RRULE の早見表、Python(dateutil)で月末や夏時間を展開して確かめた結果、表示期間に重なる予定を取る SQL も載せます。RRULE の文法を一から解説することはせず、テーブルの持ち方と変更・検索の SQLに絞ります。
確認環境(2026-10-06):Ubuntu 24.04、PostgreSQL 16.15、Python 3.11.15、python-dateutil 2.9.0.post0、psycopg2 2.9.13。掲載した SQL と Python の出力はすべてこの環境で実行したものです。Mermaid の図は @mermaid-js/mermaid-cli 12.0.0(mermaid 12.1.0)と Chromium 141 で描画できることを確かめました。RRULE の仕様は RFC 5545、ライブラリの動作は dateutil のドキュメントを同日に開いて確認しています。
この記事の結論
- 親予定(event)に RRULE 文字列・初回の開始・長さ・タイムゾーンを持ちます。 検索用に「最後の回の終了」も列で持ち、終了日なしは NULL にします。
- 例外(event_exception)は、元の開始日時をキーに1回1行。 取消はフラグ、変更は新しい開始・長さ・件名を入れます。
- 「この回だけ」は例外を1行追加、「これ以降すべて」は親を2行に分ける、「すべて」は親を1行更新します。
- 開始は現地時刻+タイムゾーン名で持ちます。 UTC にして7日ずつ足すと、夏時間の切り替えで現地の時刻が1時間ずれます。
- 表示期間の検索は、SQL で候補の親予定を絞り、アプリで展開して例外を当てる二段構えにします。
RRULE 早見表(17例・展開結果つき)
右の列は、Python の dateutil で各 RRULE を展開した最初の回です(2026年の日付は年を省略。開始日は規則に合う日を指定)。
| やりたいこと | RRULE | 最初の回 |
|---|---|---|
| 毎日 | FREQ=DAILY |
10/6(火) 10/7(水) 10/8(木) 10/9(金) 10/10(土) |
| 平日(月〜金) | FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR |
10/6(火) 10/7(水) 10/8(木) 10/9(金) 10/12(月) |
| 毎週火曜 | FREQ=WEEKLY;BYDAY=TU |
10/6(火) 10/13(火) 10/20(火) 10/27(火) 11/3(火) |
| 毎週 火・木 | FREQ=WEEKLY;BYDAY=TU,TH |
10/6(火) 10/8(木) 10/13(火) 10/15(木) 10/20(火) |
| 隔週火曜 | FREQ=WEEKLY;INTERVAL=2;BYDAY=TU |
10/6(火) 10/20(火) 11/3(火) 11/17(火) 12/1(火) |
| 毎月15日 | FREQ=MONTHLY;BYMONTHDAY=15 |
10/15(木) 11/15(日) 12/15(火) 2027/1/15(金) 2027/2/15(月) |
| 毎月第2金曜 | FREQ=MONTHLY;BYDAY=2FR |
10/9(金) 11/13(金) 12/11(金) 2027/1/8(金) 2027/2/12(金) |
| 毎月最終金曜 | FREQ=MONTHLY;BYDAY=-1FR |
10/30(金) 11/27(金) 12/25(金) 2027/1/29(金) 2027/2/26(金) |
| 毎月の月末 | FREQ=MONTHLY;BYMONTHDAY=-1 |
10/31(土) 11/30(月) 12/31(木) 2027/1/31(日) 2027/2/28(日) |
| 毎月の最終平日 | FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1 |
10/30(金) 11/30(月) 12/31(木) 2027/1/29(金) 2027/2/26(金) |
| 毎月31日 | FREQ=MONTHLY;BYMONTHDAY=31 |
10/31(土) 12/31(木) 2027/1/31(日) 2027/3/31(水) 2027/5/31(月) |
| 31日(無い月は月末) | FREQ=MONTHLY;BYMONTHDAY=28,29,30,31;BYSETPOS=-1 |
10/31(土) 11/30(月) 12/31(木) 2027/1/31(日) 2027/2/28(日) |
| 3か月ごとの1日 | FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=1 |
10/1(木) 2027/1/1(金) 2027/4/1(木) 2027/7/1(木) 2027/10/1(金) |
| 毎年10月6日 | FREQ=YEARLY |
10/6(火) 2027/10/6(水) 2028/10/6(金) 2029/10/6(土) 2030/10/6(日) |
| 毎年2月29日 | FREQ=YEARLY |
2028/2/29(火) 2032/2/29(日) 2036/2/29(金) 2040/2/29(水) 2044/2/29(月) |
| 毎週火曜を5回で終了 | FREQ=WEEKLY;BYDAY=TU;COUNT=5 |
10/6(火) 10/13(火) 10/20(火) 10/27(火) 11/3(火) |
| 毎週火曜を10/27まで | FREQ=WEEKLY;BYDAY=TU;UNTIL=20261027T100000 |
10/6(火) 10/13(火) 10/20(火) 10/27(火) |
BYDAYの曜日は MO・TU・WE・TH・FR・SA・SU。数字を付けると「第N」(2FR=第2金曜、-1FR=最終金曜)になります。BYMONTHDAY=-1は月末、BYSETPOS=-1は「その月で条件に合う日のうち最後の1つ」です。COUNT(回数)とUNTIL(終了日時)は、RFC 5545 で同じ規則に両方書かないことになっています。UNTILの日時ちょうどの回は含まれます。- 存在しない日(2月30日など)にあたる回は、RFC 5545 では「無視する」と決められています。毎月31日は、31日がある月にだけ現れます。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「繰り返し予定のDB設計|RRULEと例外日のテーブル例とSQL」
テーブル設計(CREATE TABLE と ER図)
-- 親予定:単発の予定も、繰り返しの予定も1行
CREATE TABLE event (
event_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
calendar_id integer NOT NULL,
title text NOT NULL,
tzid text NOT NULL DEFAULT 'Asia/Tokyo',
dtstart timestamp NOT NULL, -- 初回の開始(tzid の現地時刻)
duration interval NOT NULL CHECK (duration > interval '0'),
rrule text, -- 繰り返しの規則。NULL は単発
range_start timestamptz GENERATED ALWAYS AS (dtstart AT TIME ZONE tzid) STORED,
range_end timestamptz, -- 最後の回の終了。NULL は終了日なし
split_from_id bigint REFERENCES event (event_id), -- 「これ以降すべて」で分けた元の予定
CHECK (rrule IS NOT NULL OR range_end IS NOT NULL)
);
CREATE INDEX event_range_idx ON event (calendar_id, range_start);
-- 例外:元の開始日時をキーに、その回だけの上書き・取消を表す
CREATE TABLE event_exception (
event_id bigint NOT NULL REFERENCES event (event_id) ON DELETE CASCADE,
original_start timestamp NOT NULL, -- 規則どおりなら始まるはずだった日時(現地時刻)
is_cancelled boolean NOT NULL DEFAULT false,
new_start timestamp, -- 以下は上書きする項目だけ値を入れる
new_duration interval,
new_title text,
PRIMARY KEY (event_id, original_start),
CHECK (NOT is_cancelled OR (new_start IS NULL AND new_duration IS NULL AND new_title IS NULL)),
CHECK (is_cancelled OR new_start IS NOT NULL OR new_duration IS NOT NULL OR new_title IS NOT NULL)
);
CREATE INDEX event_exception_new_start_idx ON event_exception (new_start) WHERE new_start IS NOT NULL;
erDiagram
event ||--o{ event_exception : "回ごとの例外"
event |o--o{ event : "これ以降すべてで分割"
event {
bigint event_id PK
int calendar_id
text title
text tzid
timestamp dtstart
interval duration
text rrule
timestamptz range_start
timestamptz range_end
bigint split_from_id FK
}
event_exception {
bigint event_id PK
timestamp original_start PK
boolean is_cancelled
timestamp new_start
interval new_duration
text new_title
}
| 列 | 役割 | 設計の理由 |
|---|---|---|
dtstart+tzid |
初回の開始(現地時刻)とタイムゾーン名 | 「毎週10時」を夏時間の前後でも10時に保つ |
duration |
予定の長さ | 終了時刻で持つと、回ごとに計算し直す必要がある |
rrule |
繰り返しの規則。NULL は単発 | 単発と繰り返しを同じテーブルで扱える |
range_start |
初回の開始(タイムゾーン付き) | dtstart と tzid から自動で計算する生成列 |
range_end |
最後の回の終了。NULL は終了日なし | 期間検索で、終わった予定を SQL だけで除く |
split_from_id |
分割した元の予定 | 「これ以降すべて」の前後を1つの予定としてたどれる |
original_start |
規則どおりなら始まるはずだった日時 | 連番ではなく日時をキーにすると、規則を変えても対応が崩れにくい |
例外の2つの CHECK 制約は、「取消なのに変更内容が入っている行」と「何も変えていない空の行」を防ぎます。iCalendar の用語では、取消が EXDATE(除外する日時)、上書きが RECURRENCE-ID(どの回への変更かを示す日時)にあたります。
登録するデータは次のとおりです。range_end は COUNT や UNTIL がある規則だけ値を入れます(計算は後半の Python の range_end() で検算しています)。
INSERT INTO event (calendar_id, title, dtstart, duration, rrule, range_end) VALUES
(1, '週次定例', '2026-10-06 10:00', '60 min', 'FREQ=WEEKLY;BYDAY=TU', NULL),
(1, '月次報告会', '2026-10-09 15:00', '90 min', 'FREQ=MONTHLY;BYDAY=2FR;COUNT=6', '2027-03-12 16:30+09'),
(1, '棚卸し', '2026-10-23 13:00', '2 hours', NULL, '2026-10-23 15:00+09'),
(1, '月末の締め作業', '2026-10-31 18:00', '30 min', 'FREQ=MONTHLY;BYMONTHDAY=-1', NULL),
(1, '春の勉強会', '2026-04-06 17:00', '60 min', 'FREQ=WEEKLY;BYDAY=MO;UNTIL=20260831T080000Z', '2026-08-31 18:00+09'),
(1, '新人研修(全3回)', '2026-10-14 13:00', '2 hours', 'FREQ=WEEKLY;COUNT=3', '2026-10-28 15:00+09');
SET timezone = 'Asia/Tokyo'; -- 表示用。以降の結果はこの設定で出しています
SELECT event_id, title, dtstart, rrule, range_start, range_end FROM event ORDER BY event_id;
event_id | title | dtstart | rrule | range_start | range_end
----------+-------------------+---------------------+---------------------------------------------+------------------------+------------------------
1 | 週次定例 | 2026-10-06 10:00:00 | FREQ=WEEKLY;BYDAY=TU | 2026-10-06 10:00:00+09 |
2 | 月次報告会 | 2026-10-09 15:00:00 | FREQ=MONTHLY;BYDAY=2FR;COUNT=6 | 2026-10-09 15:00:00+09 | 2027-03-12 16:30:00+09
3 | 棚卸し | 2026-10-23 13:00:00 | | 2026-10-23 13:00:00+09 | 2026-10-23 15:00:00+09
4 | 月末の締め作業 | 2026-10-31 18:00:00 | FREQ=MONTHLY;BYMONTHDAY=-1 | 2026-10-31 18:00:00+09 |
5 | 春の勉強会 | 2026-04-06 17:00:00 | FREQ=WEEKLY;BYDAY=MO;UNTIL=20260831T080000Z | 2026-04-06 17:00:00+09 | 2026-08-31 18:00:00+09
6 | 新人研修(全3回) | 2026-10-14 13:00:00 | FREQ=WEEKLY;COUNT=3 | 2026-10-14 13:00:00+09 | 2026-10-28 15:00:00+09
(6 rows)
3種類の変更で、テーブルがどう変わるか
| 変更の種類 | event(親予定) | event_exception(例外) |
|---|---|---|
| この回だけ | 変えない | 1行追加(上書き、または取消) |
| これ以降すべて | 元の行の規則に UNTIL を足して閉じ、新しい行を1つ追加 |
分割点より後の行を削除、または新しい予定へ付け替え |
| すべて | 親の行を更新 | 開始時刻を変えたら original_start を同じ幅だけずらす |
この回だけ:例外を1行追加する
週次定例(毎週火曜10:00)について、10/20 の回を翌日15:00に移し、11/3 の回を休みにし、12/1 の回だけ件名を変えます。
INSERT INTO event_exception (event_id, original_start, new_start)
VALUES (1, '2026-10-20 10:00', '2026-10-21 15:00');
INSERT INTO event_exception (event_id, original_start, is_cancelled)
VALUES (1, '2026-11-03 10:00', true);
INSERT INTO event_exception (event_id, original_start, new_title)
VALUES (1, '2026-12-01 10:00', '週次定例(年末の予定確認)');
SELECT event_id, rrule, range_end FROM event WHERE event_id = 1;
SELECT * FROM event_exception WHERE event_id = 1 ORDER BY original_start;
event_id | rrule | range_end
----------+----------------------+-----------
1 | FREQ=WEEKLY;BYDAY=TU |
(1 row)
event_id | original_start | is_cancelled | new_start | new_duration | new_title
----------+---------------------+--------------+---------------------+--------------+----------------------------
1 | 2026-10-20 10:00:00 | f | 2026-10-21 15:00:00 | |
1 | 2026-11-03 10:00:00 | t | | |
1 | 2026-12-01 10:00:00 | f | | | 週次定例(年末の予定確認)
(3 rows)
親の行は変わりません。 上書きは変える項目だけに値を入れ、入れなかった項目は親の値を使います。
これ以降すべて:親を2行に分ける
11/17 の回から、木曜14:00に変えます。元の予定は 11/10 の回で終わらせ、11/19(木)から始まる新しい予定を作ります。
BEGIN;
-- 1) 元の予定を、最後に残す回(11/10 10:00)で閉じる
UPDATE event
SET rrule = 'FREQ=WEEKLY;BYDAY=TU;UNTIL='
|| to_char(timestamp '2026-11-10 10:00' AT TIME ZONE tzid AT TIME ZONE 'UTC', 'YYYYMMDD"T"HH24MISS"Z"'),
range_end = (timestamp '2026-11-10 10:00' AT TIME ZONE tzid) + duration
WHERE event_id = 1
RETURNING event_id, rrule, range_end;
-- 2) 新しい予定を作り、元の予定とつなぐ
INSERT INTO event (calendar_id, title, tzid, dtstart, duration, rrule, range_end, split_from_id)
SELECT calendar_id, title, tzid, timestamp '2026-11-19 14:00', duration, 'FREQ=WEEKLY;BYDAY=TH', NULL, event_id
FROM event WHERE event_id = 1
RETURNING event_id, title, dtstart, rrule, split_from_id;
-- 3) 分割点より後の例外は、対応する回が無くなるので削除する
DELETE FROM event_exception WHERE event_id = 1 AND original_start >= '2026-11-17 10:00'
RETURNING event_id, original_start, new_title;
COMMIT;
event_id | rrule | range_end
----------+---------------------------------------------+------------------------
1 | FREQ=WEEKLY;BYDAY=TU;UNTIL=20261110T010000Z | 2026-11-10 11:00:00+09
(1 row)
event_id | title | dtstart | rrule | split_from_id
----------+----------+---------------------+----------------------+---------------
7 | 週次定例 | 2026-11-19 14:00:00 | FREQ=WEEKLY;BYDAY=TH | 1
(1 row)
event_id | original_start | new_title
----------+---------------------+----------------------------
1 | 2026-12-01 10:00:00 | 週次定例(年末の予定確認)
(1 row)
変更後の2つのテーブルです。
event_id | title | dtstart | rrule | range_end | split_from_id
----------+----------+---------------------+---------------------------------------------+------------------------+---------------
1 | 週次定例 | 2026-10-06 10:00:00 | FREQ=WEEKLY;BYDAY=TU;UNTIL=20261110T010000Z | 2026-11-10 11:00:00+09 |
7 | 週次定例 | 2026-11-19 14:00:00 | FREQ=WEEKLY;BYDAY=TH | | 1
(2 rows)
event_id | original_start | is_cancelled | new_start
----------+---------------------+--------------+---------------------
1 | 2026-10-20 10:00:00 | f | 2026-10-21 15:00:00
1 | 2026-11-03 10:00:00 | t |
(2 rows)
要点は3つです。
UNTILは UTC(末尾に Z)で書きます。 11/10 10:00(日本時間)は20261110T010000Zです。SQL のAT TIME ZONEを2回使うと、現地時刻から UTC の文字列を作れます。- 分割点より前の例外(10/20 の移動、11/3 の取消)はそのまま残ります。
- 分割点より後の例外(12/1 の件名変更)は、火曜10:00の回そのものが無くなるので削除しました。曜日や時刻を変えずに件名だけを「これ以降すべて」変える場合は、
UPDATE event_exception SET event_id = 7 WHERE ...で新しい予定へ付け替えれば引き継げます。
event_id が 7 になっているのは、連番が6件の初期データの次だからです。初回の回を指定して「これ以降すべて」を選んだ場合は分割する意味が無いので、次の「すべて」と同じ処理にします。
すべて:親を1行更新する
月次報告会(毎月第2金曜)を、全部の回で16:00開始に変えます。先に、11/13 の回を 11/16 に移す例外を入れておきます。
INSERT INTO event_exception (event_id, original_start, new_start)
VALUES (2, '2026-11-13 15:00', '2026-11-16 15:00');
BEGIN;
UPDATE event
SET title = '月次報告会(16時開始)', dtstart = dtstart + interval '1 hour', range_end = range_end + interval '1 hour'
WHERE event_id = 2
RETURNING event_id, title, dtstart, range_start, range_end;
UPDATE event_exception SET original_start = original_start + interval '1 hour'
WHERE event_id = 2
RETURNING event_id, original_start, new_start;
COMMIT;
event_id | title | dtstart | range_start | range_end
----------+------------------------+---------------------+------------------------+------------------------
2 | 月次報告会(16時開始) | 2026-10-09 16:00:00 | 2026-10-09 16:00:00+09 | 2027-03-12 17:30:00+09
(1 row)
event_id | original_start | new_start
----------+---------------------+---------------------
2 | 2026-11-13 16:00:00 | 2026-11-16 15:00:00
(1 row)
range_start は生成列なので、dtstart を変えると自動で 16:00 になりました。例外の original_start も同じ幅だけずらすのを忘れないようにします。ずらさないと、例外のキー(15:00)が新しい規則のどの回(16:00)にも合わなくなり、11/13 16:00 の回と 11/16 に移した回の両方が表示されます。個別に移した回(new_start)をどうするかは仕様で決めます。ここでは利用者が自分で決めた日時なので、そのまま残しています。
表示期間に重なる予定を取る SQL
2026年11月(日本時間)の表示に必要な予定を取ります。その前に、新人研修の最終回(10/28)を 11/4 に移しておきます。
INSERT INTO event_exception (event_id, original_start, new_start)
VALUES (6, '2026-10-28 13:00', '2026-11-04 13:00');
まず、親予定の範囲だけで絞る SQL です。
SELECT e.event_id, e.title FROM event e
WHERE e.calendar_id = 1
AND e.range_start < timestamptz '2026-12-01 00:00+09'
AND (e.range_end IS NULL OR e.range_end > timestamptz '2026-11-01 00:00+09')
ORDER BY e.event_id;
event_id | title
----------+------------------------
1 | 週次定例
2 | 月次報告会(16時開始)
4 | 月末の締め作業
7 | 週次定例
(4 rows)
終わった予定(3 棚卸し、5 春の勉強会)は除かれましたが、11/4 に移した新人研修(6)も抜けています。 親の範囲は 10/28 で終わっているためです。範囲の外へ移した回も拾うには、例外の new_start も条件に加えます。
SELECT e.event_id, e.title, e.tzid, e.dtstart, e.duration, e.rrule
FROM event e
WHERE e.calendar_id = 1
AND ( ( e.range_start < timestamptz '2026-12-01 00:00+09'
AND (e.range_end IS NULL OR e.range_end > timestamptz '2026-11-01 00:00+09'))
OR EXISTS (SELECT 1 FROM event_exception x
WHERE x.event_id = e.event_id AND x.new_start IS NOT NULL
AND (x.new_start AT TIME ZONE e.tzid) < timestamptz '2026-12-01 00:00+09'
AND (x.new_start AT TIME ZONE e.tzid) + COALESCE(x.new_duration, e.duration)
> timestamptz '2026-11-01 00:00+09') )
ORDER BY e.event_id;
event_id | title | tzid | dtstart | duration | rrule
----------+------------------------+------------+---------------------+----------+---------------------------------------------
1 | 週次定例 | Asia/Tokyo | 2026-10-06 10:00:00 | 01:00:00 | FREQ=WEEKLY;BYDAY=TU;UNTIL=20261110T010000Z
2 | 月次報告会(16時開始) | Asia/Tokyo | 2026-10-09 16:00:00 | 01:30:00 | FREQ=MONTHLY;BYDAY=2FR;COUNT=6
4 | 月末の締め作業 | Asia/Tokyo | 2026-10-31 18:00:00 | 00:30:00 | FREQ=MONTHLY;BYMONTHDAY=-1
6 | 新人研修(全3回) | Asia/Tokyo | 2026-10-14 13:00:00 | 02:00:00 | FREQ=WEEKLY;COUNT=3
7 | 週次定例 | Asia/Tokyo | 2026-11-19 14:00:00 | 01:00:00 | FREQ=WEEKLY;BYDAY=TH
(5 rows)
重なりの条件は「予定の開始が表示期間の終わりより前」かつ「予定の終了が表示期間の始まりより後」です。SQL が返すのは「この期間に回がありそうな親予定」までで、実際の回の日時は次の Python で求めます。
Python(dateutil)で展開して、例外を当てる
# expand.py : 表示期間に重なる予定を DB から取り、RRULE を展開して例外を当てる
import re
from datetime import datetime
from zoneinfo import ZoneInfo
import psycopg2
import psycopg2.extras
from dateutil.rrule import rrulestr
JST = ZoneInfo("Asia/Tokyo")
WD = "月火水木金土日"
SQL_EVENTS = """
SELECT e.event_id, e.title, e.tzid, e.dtstart, e.duration, e.rrule
FROM event e
WHERE e.calendar_id = %(cal)s
AND ( ( e.range_start < %(to)s
AND (e.range_end IS NULL OR e.range_end > %(from)s))
OR EXISTS (SELECT 1 FROM event_exception x
WHERE x.event_id = e.event_id AND x.new_start IS NOT NULL
AND (x.new_start AT TIME ZONE e.tzid) < %(to)s
AND (x.new_start AT TIME ZONE e.tzid) + COALESCE(x.new_duration, e.duration) > %(from)s) )
ORDER BY e.event_id
"""
SQL_EXCEPTIONS = "SELECT * FROM event_exception WHERE event_id = ANY(%s)"
def expand(ev, exceptions, win_from, win_to):
"""1つの親予定を、表示期間 [win_from, win_to) に重なる回のリストにする"""
tz = ZoneInfo(ev["tzid"])
start = ev["dtstart"].replace(tzinfo=tz)
if ev["rrule"]:
rule = rrulestr(ev["rrule"], dtstart=start)
base = rule.between(win_from - ev["duration"], win_to, inc=True)
else:
base = [start]
rows = []
for occ in base: # 規則どおりの回(例外がある回は除く)
if occ.replace(tzinfo=None) not in exceptions:
rows.append((occ, ev["duration"], ev["title"], ""))
for ex in exceptions.values(): # 上書きされた回(取消は出さない)
if not ex["is_cancelled"]:
s = (ex["new_start"] or ex["original_start"]).replace(tzinfo=tz)
note = f"変更(元は {ex['original_start']:%m/%d %H:%M})"
rows.append((s, ex["new_duration"] or ev["duration"], ex["new_title"] or ev["title"], note))
return [(s, s + d, t, n) for s, d, t, n in rows if s < win_to and s + d > win_from]
def range_end(ev):
"""COUNT か UNTIL がある規則の、最後の回の終了日時。どちらも無ければ None(終了日なし)"""
if not ev["rrule"]:
return ev["dtstart"].replace(tzinfo=ZoneInfo(ev["tzid"])) + ev["duration"]
if not re.search(r"(COUNT|UNTIL)=", ev["rrule"]):
return None
last = list(rrulestr(ev["rrule"], dtstart=ev["dtstart"].replace(tzinfo=ZoneInfo(ev["tzid"]))))[-1]
return last + ev["duration"]
if __name__ == "__main__":
win_from = datetime(2026, 11, 1, tzinfo=JST)
win_to = datetime(2026, 12, 1, tzinfo=JST)
conn = psycopg2.connect("dbname=appdb")
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(SQL_EVENTS, {"cal": 1, "from": win_from, "to": win_to})
events = cur.fetchall()
cur.execute(SQL_EXCEPTIONS, ([e["event_id"] for e in events],))
by_event = {}
for x in cur.fetchall():
by_event.setdefault(x["event_id"], {})[x["original_start"]] = x
result = []
for ev in events:
for s, e, title, note in expand(ev, by_event.get(ev["event_id"], {}), win_from, win_to):
result.append((s, e, ev["event_id"], title, note))
for s, e, eid, title, note in sorted(result):
print(f"{s:%m/%d}({WD[s.weekday()]}) {s:%H:%M}-{e:%H:%M} #{eid} {title} {note}")
print("--- range_end の検算 ---")
for ev in events:
print(f"#{ev['event_id']} {ev['rrule']} -> {range_end(ev)}")
接続先は dbname=appdb に置き換えて載せています。実行結果は次のとおりです。
$ python3 expand.py
11/04(水) 13:00-15:00 #6 新人研修(全3回) 変更(元は 10/28 13:00)
11/10(火) 10:00-11:00 #1 週次定例
11/16(月) 15:00-16:30 #2 月次報告会(16時開始) 変更(元は 11/13 16:00)
11/19(木) 14:00-15:00 #7 週次定例
11/26(木) 14:00-15:00 #7 週次定例
11/30(月) 18:00-18:30 #4 月末の締め作業
--- range_end の検算 ---
#1 FREQ=WEEKLY;BYDAY=TU;UNTIL=20261110T010000Z -> 2026-11-10 11:00:00+09:00
#2 FREQ=MONTHLY;BYDAY=2FR;COUNT=6 -> 2027-03-12 17:30:00+09:00
#4 FREQ=MONTHLY;BYMONTHDAY=-1 -> None
#6 FREQ=WEEKLY;COUNT=3 -> 2026-10-28 15:00:00+09:00
#7 FREQ=WEEKLY;BYDAY=TH -> None
11/3 の取消、11/10 で終わる元の予定、11/19 から始まる分割後の予定、移動した2件が、どれも意図どおりに出ています。between() の始まりを予定の長さだけ手前にずらしているのは、表示期間の前に始まって期間内まで続く回を拾うためです。
月末・31日指定・隔週・分割を検算する
# edge.py : 月末・隔週・分割で見落としやすい点を確かめる
from datetime import datetime
from dateutil.rrule import rrulestr
def show(label, rule, start, n=5):
occ = rrulestr(rule, dtstart=start).xafter(start, count=n, inc=True)
print(f"{label}: " + " ".join(f"{d:%Y-%m-%d}" for d in occ))
# 1) 31日に始めた「毎月」は、31日が無い月を飛ばす
show("毎月(31日開始・日の指定なし)", "FREQ=MONTHLY", datetime(2026, 10, 31, 18, 0), 4)
r = rrulestr("FREQ=MONTHLY;BYMONTHDAY=31", dtstart=datetime(2026, 10, 31, 18, 0))
print("2027年の回数(BYMONTHDAY=31):", len(r.between(datetime(2027, 1, 1), datetime(2028, 1, 1))))
r = rrulestr("FREQ=MONTHLY;BYMONTHDAY=-1", dtstart=datetime(2026, 10, 31, 18, 0))
print("2027年の回数(BYMONTHDAY=-1):", len(r.between(datetime(2027, 1, 1), datetime(2028, 1, 1))))
# 2) 隔週で日曜を含むとき、週の始まり(WKST)で結果が変わる
show("隔週 火・日(WKST 指定なし=月曜始まり)", "FREQ=WEEKLY;INTERVAL=2;BYDAY=TU,SU", datetime(2026, 10, 6, 10, 0))
show("隔週 火・日(WKST=SU=日曜始まり) ", "FREQ=WEEKLY;INTERVAL=2;BYDAY=TU,SU;WKST=SU", datetime(2026, 10, 6, 10, 0))
# 3) COUNT=6 の予定を4回目から分けるとき:前半は UNTIL、後半は残りの回数にする
whole = list(rrulestr("FREQ=MONTHLY;BYDAY=2FR;COUNT=6", dtstart=datetime(2026, 10, 9, 15, 0)))
head = list(rrulestr("FREQ=MONTHLY;BYDAY=2FR;UNTIL=20261211T150000", dtstart=datetime(2026, 10, 9, 15, 0)))
tail = list(rrulestr("FREQ=MONTHLY;BYDAY=2FR;COUNT=3", dtstart=datetime(2027, 1, 8, 15, 0)))
print("分割前:", len(whole), "回 / 前半:", len(head), "回 / 後半:", len(tail), "回 / 一致:", whole == head + tail)
# 4) 10年前に始まった毎日の予定から、1か月分だけ取り出す
import time
t = time.perf_counter()
r = rrulestr("FREQ=DAILY", dtstart=datetime(2016, 10, 6, 10, 0))
n = len(r.between(datetime(2026, 11, 1), datetime(2026, 12, 1)))
print(f"10年前開始の毎日の予定から11月分: {n}回({(time.perf_counter() - t) * 1000:.1f} ミリ秒)")
毎月(31日開始・日の指定なし): 2026-10-31 2026-12-31 2027-01-31 2027-03-31
2027年の回数(BYMONTHDAY=31): 7
2027年の回数(BYMONTHDAY=-1): 12
隔週 火・日(WKST 指定なし=月曜始まり): 2026-10-06 2026-10-11 2026-10-20 2026-10-25 2026-11-03
隔週 火・日(WKST=SU=日曜始まり) : 2026-10-06 2026-10-18 2026-10-20 2026-11-01 2026-11-03
分割前: 6 回 / 前半: 3 回 / 後半: 3 回 / 一致: True
10年前開始の毎日の予定から11月分: 30回(6.8 ミリ秒)
- 31日に始めた「毎月」は、11月・2月などを飛ばして年7回になります。月末に行いたい予定は
BYMONTHDAY=-1(年12回)で登録します。画面で31日を選んだときに「毎月31日」と「毎月の月末」のどちらにするかを聞くと、行き違いを防げます。 - 隔週で日曜を含む予定は、週の始まりで日付が変わります。 RFC 5545 の既定は月曜始まりです。日曜始まりのカレンダーを表示している場合は
WKST=SUを付けます。 COUNT付きの予定を途中で分けるときは、前半をUNTIL、後半を残りの回数のCOUNTにすると、分割前と同じ6回になりました。- 終了日なしの予定でも、
between()で期間を指定すれば、その期間の回だけが返ります。
夏時間をまたぐ展開:現地時刻で持つ理由
ニューヨークの現地 9:00 に毎週行う予定を、夏時間が終わる 2026-11-01 をまたいで展開します。
# dst.py : 夏時間をまたぐ展開。現地時刻で持つ場合と、UTC の固定間隔で足す場合を比べる
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from dateutil.rrule import rrulestr
from dateutil import tz as dtz
NY = ZoneInfo("America/New_York")
start = datetime(2026, 10, 20, 9, 0, tzinfo=NY) # 現地 9:00 の週次予定
print("A. 現地時刻+タイムゾーンで展開(RRULE)")
for d in rrulestr("FREQ=WEEKLY;COUNT=4", dtstart=start):
print(" 現地", d.strftime("%m/%d %H:%M %Z"), "| UTC", d.astimezone(timezone.utc).strftime("%m/%d %H:%M"))
print("B. 最初の回を UTC にして 7日ずつ足す")
u = start.astimezone(timezone.utc)
for i in range(4):
d = u + timedelta(days=7 * i)
print(" 現地", d.astimezone(NY).strftime("%m/%d %H:%M %Z"), "| UTC", d.strftime("%m/%d %H:%M"))
print("C. 存在しない時刻(2027-03-14 は 2:00 の次が 3:00)")
for d in rrulestr("FREQ=DAILY;COUNT=3", dtstart=datetime(2027, 3, 13, 2, 30, tzinfo=NY)):
print(" 現地", d.strftime("%m/%d %H:%M %Z"), "| 存在する:", dtz.datetime_exists(d),
"| UTC", d.astimezone(timezone.utc).strftime("%m/%d %H:%M"),
"| UTCから戻すと", d.astimezone(timezone.utc).astimezone(NY).strftime("%H:%M %Z"))
print("D. UNTIL の書き方")
for until in ("20261103T140000Z", "20261103T090000"):
try:
r = rrulestr(f"FREQ=WEEKLY;UNTIL={until}", dtstart=start)
print(f" UNTIL={until}: {len(list(r))}回")
except ValueError as e:
print(f" UNTIL={until}: ValueError: {e}")
naive = datetime(2026, 10, 20, 9, 0)
for until in ("20261103T140000Z", "20261103T090000"):
try:
r = rrulestr(f"FREQ=WEEKLY;UNTIL={until}", dtstart=naive)
print(f" (タイムゾーンなしの開始)UNTIL={until}: {len(list(r))}回")
except ValueError as e:
print(f" (タイムゾーンなしの開始)UNTIL={until}: ValueError: {e}")
A. 現地時刻+タイムゾーンで展開(RRULE)
現地 10/20 09:00 EDT | UTC 10/20 13:00
現地 10/27 09:00 EDT | UTC 10/27 13:00
現地 11/03 09:00 EST | UTC 11/03 14:00
現地 11/10 09:00 EST | UTC 11/10 14:00
B. 最初の回を UTC にして 7日ずつ足す
現地 10/20 09:00 EDT | UTC 10/20 13:00
現地 10/27 09:00 EDT | UTC 10/27 13:00
現地 11/03 08:00 EST | UTC 11/03 13:00
現地 11/10 08:00 EST | UTC 11/10 13:00
C. 存在しない時刻(2027-03-14 は 2:00 の次が 3:00)
現地 03/13 02:30 EST | 存在する: True | UTC 03/13 07:30 | UTCから戻すと 02:30 EST
現地 03/14 02:30 EST | 存在する: False | UTC 03/14 07:30 | UTCから戻すと 03:30 EDT
現地 03/15 02:30 EDT | 存在する: True | UTC 03/15 06:30 | UTCから戻すと 02:30 EDT
D. UNTIL の書き方
UNTIL=20261103T140000Z: 3回
UNTIL=20261103T090000: ValueError: RRULE UNTIL values must be specified in UTC when DTSTART is timezone-aware
(タイムゾーンなしの開始)UNTIL=20261103T140000Z: ValueError: RRULE UNTIL values must be specified in UTC when DTSTART is timezone-aware
(タイムゾーンなしの開始)UNTIL=20261103T090000: 3回
- A は現地 9:00 を保ち、B は 11/3 から現地 8:00 にずれました。 開始を UTC の1点として持ち、固定の間隔を足す方法は、夏時間のある地域の予定に向きません。
dtstartを現地時刻、tzidをタイムゾーン名で持つのはこのためです。 - C のとおり、dateutil は存在しない現地時刻(3/14 2:30)の回も返します。 RFC 5545 は「無視する」と決めていますが、動作が違います。UTC に直すと 3:30 として扱われるので、深夜2時台の予定を扱うなら
dateutil.tz.datetime_exists()で確かめて、飛ばすか繰り下げるかを決めます。 - D のとおり、開始がタイムゾーン付きなら
UNTILは UTC(末尾 Z)、タイムゾーンなしならUNTILも Z なしで書きます。食い違うと ValueError になります。この記事のテーブルは、開始にタイムゾーンを付けて展開するので、UNTILは必ず UTC で保存します。
PostgreSQL 側でも、現地時刻とタイムゾーン名から UTC を求められます。11/3 は 14:00 UTC(日本時間 23:00)、10/27 は 13:00 UTC(同 22:00)で、Python の結果と一致します。
SELECT timestamp '2026-11-03 09:00' AT TIME ZONE 'America/New_York' AS utc_nov,
timestamp '2026-10-27 09:00' AT TIME ZONE 'America/New_York' AS utc_oct;
utc_nov | utc_oct
------------------------+------------------------
2026-11-03 23:00:00+09 | 2026-10-27 22:00:00+09
(1 row)
3つの持ち方の違いと選び方
RRULE 文字列のほかに、全部の回を行で持つ方式と、周期を列に分けて持つ方式があります。同じデータで2つを作って比べました。
-- 事前展開方式:回ごとに1行
CREATE TABLE occurrence (
event_id bigint NOT NULL REFERENCES event (event_id) ON DELETE CASCADE,
start_at timestamptz NOT NULL,
end_at timestamptz NOT NULL,
title text NOT NULL,
PRIMARY KEY (event_id, start_at)
);
CREATE INDEX occurrence_start_idx ON occurrence (start_at);
-- 毎週木曜 14:00 を1年分展開して入れる
INSERT INTO occurrence (event_id, start_at, end_at, title)
SELECT 7, g AT TIME ZONE 'Asia/Tokyo', (g + interval '60 min') AT TIME ZONE 'Asia/Tokyo', '週次定例'
FROM generate_series(timestamp '2026-11-19 14:00', timestamp '2027-11-18 14:00', interval '7 days') AS g;
SELECT count(*) AS rows_1year FROM occurrence;
SELECT event_id, start_at, end_at FROM occurrence
WHERE start_at < timestamptz '2026-12-01 00:00+09' AND end_at > timestamptz '2026-11-01 00:00+09'
ORDER BY start_at;
rows_1year
------------
53
(1 row)
event_id | start_at | end_at
----------+------------------------+------------------------
7 | 2026-11-19 14:00:00+09 | 2026-11-19 15:00:00+09
7 | 2026-11-26 14:00:00+09 | 2026-11-26 15:00:00+09
(2 rows)
-- 周期カラム方式:規則を列に分ける
CREATE TABLE event_pattern (
event_id bigint PRIMARY KEY REFERENCES event (event_id) ON DELETE CASCADE,
freq text NOT NULL CHECK (freq IN ('DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY')),
interval_n integer NOT NULL DEFAULT 1 CHECK (interval_n >= 1),
by_weekday smallint[], -- 1=月 … 7=日
by_monthday smallint, -- 1〜31、-1 は月末
by_setpos smallint, -- 第N(-1 は最終)
starts_on date NOT NULL,
ends_on date,
start_time time NOT NULL
);
INSERT INTO event_pattern (event_id, freq, by_weekday, starts_on, ends_on, start_time) VALUES
(1, 'WEEKLY', '{2}', '2026-10-06', '2026-11-10', '10:00'),
(7, 'WEEKLY', '{4}', '2026-11-19', NULL, '14:00');
INSERT INTO event_pattern (event_id, freq, by_weekday, by_setpos, starts_on, ends_on, start_time) VALUES
(2, 'MONTHLY', '{5}', 2, '2026-10-09', '2027-03-12', '16:00');
INSERT INTO event_pattern (event_id, freq, by_monthday, starts_on, start_time) VALUES
(4, 'MONTHLY', -1, '2026-10-31', '18:00');
-- 「11/26 の時点で、木曜日に入っている週次の予定」を SQL だけで探す
SELECT p.event_id, e.title, p.start_time FROM event_pattern p JOIN event e USING (event_id)
WHERE p.freq = 'WEEKLY' AND 4 = ANY (p.by_weekday)
AND p.starts_on <= date '2026-11-26' AND (p.ends_on IS NULL OR p.ends_on >= date '2026-11-26');
event_id | title | start_time
----------+----------+------------
7 | 週次定例 | 14:00:00
(1 row)
| 観点 | RRULE 文字列で持つ | 全回を事前展開して行で持つ | 周期を列に分けて持つ |
|---|---|---|---|
| 期間検索 | SQL で候補を絞り、アプリで展開 | SQL だけで完結 | 単純な規則は SQL、複雑な規則はアプリ |
| 終了日なし | そのまま持てる(range_end が NULL) |
持てない。先の分を定期的に足す | 持てる(ends_on が NULL) |
| 「すべて」の変更 | 親1行を更新 | 全部の行を更新(週次1年分で53行) | 1行を更新 |
| 「この回だけ」の変更 | 例外を1行追加 | その回の行を更新 | 例外テーブルを別に用意する |
| 表せる規則 | RFC 5545 の規則すべて | どんな並びでも持てる | 列を用意した範囲だけ |
| 他のカレンダーとの受け渡し | 文字列をそのまま渡せる | 規則に戻せない | 変換が要る |
| SQL での集計(回数・空き時間) | 向かない | 得意 | 一部だけ |
| 条件 | 選ぶもの |
|---|---|
| 終了日なしの予定がある、規則の種類が多い | RRULE 文字列 |
| iCalendar 形式で取り込み・書き出しをする | RRULE 文字列 |
| 回数が決まっている(全10回の講座など)、回ごとに出欠や担当を持つ | 事前展開 |
| 空き時間の検索や重複の検査を SQL で行いたい | 事前展開(または RRULE+先数か月分の展開を併用) |
| 規則が「毎週◯曜」「毎月◯日」だけで、SQL から直接条件にしたい | 周期カラム |
| 迷ったら | RRULE 文字列+例外テーブル。必要になったら先の分だけ展開して持つ |
EXDATE・RECURRENCE-ID・UNTIL・COUNT の違い
| 用語 | 表すもの | この記事のテーブルでは |
|---|---|---|
| RRULE | 繰り返しの規則 | event.rrule |
| EXDATE | 規則から除く日時(その回は無い) | event_exception.is_cancelled = true |
| RECURRENCE-ID | どの回への変更かを示す、元の日時 | event_exception.original_start |
| RANGE=THISANDFUTURE | その回と、それ以降すべてへの変更 | 親を2行に分けて split_from_id でつなぐ |
| UNTIL | この日時まで(ちょうどの回を含む) | event.rrule の中。あわせて range_end に終了を入れる |
| COUNT | 回数 | event.rrule の中。最後の回を計算して range_end に入れる |
確かめて分かったこと
- 範囲の外へ移した回は、親の範囲だけの検索では抜けます。 最終回を翌週に移した研修は、例外の
new_startを条件に加えるまで11月の一覧に出ませんでした。 - dateutil は、存在しない現地時刻の回を飛ばさずに返します。 RFC 5545 の決まりとは違うので、深夜の予定では自分で確かめる処理を足します。
- タイムゾーンなしの開始に
UNTIL=...Zを組み合わせると ValueError になります。 エラー文は「開始がタイムゾーン付きなら UTC で」という内容で、原因が逆向きに読めるので気づきにくいところです。 dtstart AT TIME ZONE tzidは生成列に使えました。 存在しないタイムゾーン名を入れるとERROR: time zone "Asia/Tokio" not recognizedになり、入力の検査も兼ねられます。- 「すべて」で開始時刻を変えたら、例外のキーも一緒にずらす必要があります。 ずらさないと、元の回と移動後の回が両方出ます。
- 31日開始の「毎月」は年7回です。
BYMONTHDAYを書かなくても、開始日の31日が使われました。
よくある質問
Q. RRULE とは何ですか?
A. iCalendar(RFC 5545)で決められた、繰り返しの規則を表す文字列です。FREQ=WEEKLY;BYDAY=TU のように、頻度(FREQ)と条件(BYDAY など)をセミコロンで並べて書きます。
Q. 繰り返し予定は、全部の回をレコードにして持つべきですか? A. 終了日なしの予定があるなら、規則を1行で持つ方式が向いています。回数が決まっていて回ごとに出欠などを持つ場合は、全部の回を行にする方式が扱いやすくなります。
Q. 「この回だけ変更」はどう持ちますか? A. 元の開始日時をキーにした例外テーブルに1行追加します。変える項目(開始・長さ・件名)だけに値を入れ、取消はフラグで表します。親予定の行は変えません。
Q. 「これ以降すべて変更」で、過去の回が変わってしまわないようにするには? A. 元の予定の規則に UNTIL を足して手前で終わらせ、変更後の内容で新しい予定を作ります。親を書き換えるだけだと、過去の回まで新しい時間で表示されます。
Q. 毎月31日の予定が、31日の無い月に表示されないときは?
A. 規則どおりの動作です。存在しない日の回は作られません。月末に行いたい予定は FREQ=MONTHLY;BYMONTHDAY=-1 で登録します。
Q. 開始日時は UTC で保存しないのですか? A. 繰り返しの開始は、現地時刻とタイムゾーン名で保存します。UTC の1点から固定の間隔を足すと、夏時間の切り替えで現地の時刻がずれるためです。検索用の範囲(最初の開始・最後の終了)はタイムゾーン付きの日時で持ちます。
まとめ
- 親予定に RRULE 文字列・初回の開始・長さ・タイムゾーンを1行で持ち、単発の予定も同じテーブルで扱います。
- 例外は元の開始日時をキーにした別テーブルで、上書きと取消を1回1行で表します。
- 「この回だけ」は例外を追加、「これ以降すべて」は UNTIL で閉じて新しい行を追加、「すべて」は親を更新します。
- 期間検索は、
range_startとrange_endで親を絞り、例外の移動先も条件に加えてから、アプリで展開します。 - 開始は現地時刻+タイムゾーン名で持ち、
UNTILは UTC で書きます。 - 月末は
BYMONTHDAY=-1、隔週で日曜を含むならWKSTを確かめます。 - 回ごとの情報や SQL での集計が要るなら、先の分を展開して持つ方式を併用します。
参考・出典
- RFC 5545 Internet Calendaring and Scheduling Core Object Specification (iCalendar)(3.2.13 Recurrence Identifier Range、3.3.10 Recurrence Rule、3.8.4.4 Recurrence ID、3.8.5.1 Exception Date-Times)https://www.rfc-editor.org/rfc/rfc5545.html
- dateutil ドキュメント「rrule」https://dateutil.readthedocs.io/en/stable/rrule.html
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「繰り返し予定のDB設計|RRULEと例外日のテーブル例とSQL」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。