繰り返し予定の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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?