PostgreSQLでの繰り返しスケジュールとタイムゾーン: パターン
実用的な保存形式、再発ルール、例外、およびカレンダーを正しく保つクエリパターンを使って、PostgreSQLでの繰り返しスケジュールとタイムゾーンを学びます。

タイムゾーンと繰り返しイベントがうまくいかない理由
多くのカレンダーバグは数学の間違いではなく、意味のズレです。あなたはあるもの(時刻の瞬間)を保存しているが、ユーザーは別のもの(特定の場所でのローカルな時計時刻)を期待します。このギャップが、繰り返しスケジュールとタイムゾーンがテストでは正しく見えても、実際の利用で壊れる原因です。
夏時間(DST)は典型的な引き金です。「毎週日曜09:00」は「開始タイムスタンプから7日ごと」と同じではありません。オフセットが変わると両者は1時間ずれ、カレンダーはいつのまにか間違ってしまいます。
旅行や混在タイムゾーンはさらに複雑化します。予約が物理的な場所(シカゴのサロンの椅子)に結びついているのに、閲覧者がロンドンにいるとします。場所ベースのスケジュールを人ベースとして扱うと、少なくとも片方に間違った現地時刻を表示してしまいます。
よくある失敗パターン:
- 保存したタイムスタンプに間隔を足して再発生を生成し、DST が変わる。
- ゾーンルールなしで「ローカル時刻」を保存し、意図した瞬間を再構築できない。
- DST 境界をまたがない日付しかテストしていない。
- クエリ内で「イベントのタイムゾーン」「ユーザーのタイムゾーン」「サーバーのタイムゾーン」を混同している。
スキーマを選ぶ前に、「正しい」とは何かをプロダクトで決めてください。
予約の場合、「正しい」は通常こうです: 会場のタイムゾーンで意図した壁時計時刻にアポイントメントが発生し、閲覧者には正しい変換が表示されること。
シフトの場合、「正しい」は店ごとに固定の現地時刻で開始すること(従業員が移動していても同じ)であることが多いです。
「スケジュールを場所に紐づけるか、人に紐づけるか」の決定が、保存する内容、再発生の生成方法、カレンダー表示のクエリ設計を左右します。
正しいメンタルモデルを選ぶ: インスタント vs ローカル時間
多くのバグは2つの異なる時間の考え方を混同することから生じます:
- インスタント: 絶対的な瞬間で、一度だけ起きる点。
- ローカル時間ルール: 「パリで毎週月曜9:00」のような壁時計の時刻。
インスタントはどこでも同じです。2026-03-10 14:00 UTC はインスタントです。ビデオ通話やフライトの出発、正確な瞬間に通知を送るといった用途は通常インスタントです。
ローカル時間は場所の時計で読む時刻です。Europe/Paris の毎平日 9:00 のような表現はローカル時間です。店舗営業時間や定期クラス、スタッフシフトは通常、場所のタイムゾーンに紐づきます。タイムゾーンは表示の好みではなく意味の一部です。
単純なルール:
- イベントが世界で一つの実際の瞬間として起こる必要があるなら、開始/終了は
timestamptzで保存する。 - イベントがある場所の時計に従うなら、ローカルの日付とローカル時刻にゾーンIDを組み合わせて保存する。
- ユーザーが移動するなら、表示時に閲覧者のゾーンで見せつつ、スケジュールは元のゾーンに固定する。
+02:00のようなオフセットからゾーンを推測しない。オフセットは DST ルールを含まない。
例: 病院のシフトが Mon-Fri 09:00-17:00 America/New_York であれば、DST 変更週でも現地では9時から5時のままで、UTC の瞬間が1時間動くのは問題ではありません。
PostgreSQL で重要な型(避けるべきもの含む)
多くのカレンダーバグは誤ったカラム型から始まります。重要なのは「実際の瞬間」と「壁時計の期待」を分離することです。
実際のインスタントには timestamptz を使ってください: 予約、打刻、通知、ユーザーや地域を越えて比較するものはこれです。PostgreSQL はこれを絶対時刻として保存し、表示時に変換するため、順序や重複チェックが期待通りに動きます。
ローカルな壁時計値には timestamp without time zone を使います(単体ではインスタントにならないもの)。例: 「毎週月曜09:00」や「店は10:00開店」のような値。これにタイムゾーン識別子を組み合わせ、発生を生成するときに実際のインスタントに変換します。
繰り返しパターンで便利な基本型:
date: 日単位の例外(祝日)time: 日ごとの開始時刻interval: 継続時間(例: 6時間シフト)
タイムゾーンは IANA 名(例: America/New_York)を text カラム(または小さな参照テーブル)で保存してください。-0500 のようなオフセットだけでは DST ルールを含まないため不十分です。
多くのアプリにとって実用的なセット:
- 予約の開始/終了インスタント:
timestamptz - 例外日は
date - 繰り返しの開始時刻は
time - 継続時間は
interval - IANA タイムゾーンIDは
text
予約・シフトアプリ向けのデータモデルオプション
最適なスキーマはスケジュールの変更頻度とユーザーがどれだけ先を参照するかによります。一般に、事前に多数の行を書き込むか、表示時に生成するかの選択です。
オプション A: すべての発生を保存する
1行を1シフト/1予約(展開済み)で挿入します。クエリは簡単で考えやすいですが、ルール変更時の更新が多く書き込みが増えます。
イベントが一度きりが多い、または先の期間を短くしか生成しない(例: 次の30日分)場合に有効です。
オプション B: ルールを保存して読み取り時に展開する
「毎週月・水09:00 America/New_York」のようなスケジュールルールを保存し、要求された範囲の発生をオンデマンドで生成します。
柔軟でストレージ効率は良いですが、クエリは複雑になりやすく、月表示などはキャッシュしないと遅くなり得ます。
オプション C: ルール+キャッシュされた発生(ハイブリッド)
ルールを真実のソースとして保持しつつ、ローリングウィンドウ(例: 60〜90日)分の生成済み発生を保存します。ルールが変わったらキャッシュを再生成します。
シフトアプリの強力なデフォルトです: 月表示が速く、パターンの編集は一箇所で済みます。
実践的なテーブル群:
- schedule: owner/resource、タイムゾーン、ローカル開始時刻、継続時間、再発ルール
- occurrence: 展開されたインスタンス、
start_at timestamptz,end_at timestamptz、ステータス - exception: 「この日はスキップ」や「この日は異なる」マーカー
- override: 個々の発生に対する編集(開始時刻変更、スタッフ入れ替え、キャンセルフラグ)
- (任意)schedule_cache_state: 最後に生成した範囲を保持
カレンダーレンジクエリのために「このウィンドウのすべてを見せる」用途でインデックスを貼る:
- occurrence 上:
btree (resource_id, start_at)と通常btree (resource_id, end_at) - 範囲重なりをよく問い合わせるなら:
tstzrange(start_at, end_at)を生成列にしてgistインデックス
脆弱にならない再発ルールの表現
再発スケジュールは、ルールが巧妙すぎたり柔軟すぎたり、クエリできないバイナリの塊(blob)として保存されたりすると壊れます。良いルール形式はチームが検証でき、説明できるものです。
2つの一般的なアプローチ:
- 実際にサポートするパターンに限定した シンプルなカスタムフィールド。
- カレンダーのインポート/エクスポートや多様な組み合わせを扱う必要がある場合の iCalendar 風(RRULE 相当)。
現実的な折衷案: 限られたオプションを列として保存し、RRULE 文字列は交換フォーマットのみとして扱う。
例として、週単位シフトは以下のようなフィールドで表せます:
freq(daily/weekly/monthly) とinterval(毎 N)byweekday(0-6 の配列またはビットマスク)- 月毎ルールならオプションの
bymonthday(1-31) starts_at_local(ユーザーが選んだローカル日時)とtzid- オプションで
until_dateまたはcount(両方をサポートするのは避ける)
境界については、各発生の end を毎回保存するよりも duration(例: 8時間)を保存する方が好ましいです。DST が変わっても duration は安定しており、発生ごとの終了時刻は start + duration で計算できます。
ルールを展開するときは安全かつ限定的に:
window_startとwindow_endの範囲内だけ展開する。- 夜跨ぎイベントのために小さなバッファ(例: 1日)を追加する。
- 最大インスタンス数(例: 500)で打ち切る。
- 生成前に候補をフィルタする(
tzid,freq, 開始日など)。
ステップバイステップ: DST に安全な繰り返しスケジュールの構築
信頼できるパターンは: 各発生をまずローカルなカレンダーの意図(日付 + ローカル時刻 + 場所のタイムゾーン)として扱い、ソートや競合チェック、表示が必要なときに初めてインスタントに変換することです。
1) UTC 推測ではなくローカルの意図を保存する
スケジュールの場所のタイムゾーン(IANA 名 例: America/New_York)とローカル開始時刻(例: 09:00)を保存してください。そのローカル時刻がビジネスの意味するところです。DST が変わってもこのローカル時間が基準になります。
また継続時間と明確な境界(開始日と終了日または繰り返し回数)を保存してください。境界は「無限展開」バグを防ぎます。
2) 例外とオーバーライドは別でモデル化する
スキップ日用と変更発生用の2つの小さなテーブルを用意し、schedule_id + local_date でキーを持たせると元の再発ときれいにマッチできます。
実用的な形は次の通りです:
-- core schedule
-- tz is the location time zone
-- start_time is local wall-clock time
schedule(id, tz text, start_date date, end_date date, start_time time, duration_mins int, by_dow int[])
schedule_skip(schedule_id, local_date date)
schedule_override(schedule_id, local_date date, new_start_time time, new_duration_mins int)
3) 要求ウィンドウ内だけを展開する
表示レンジ(週、月)に対して候補のローカル日を生成し、曜日でフィルタしてからスキップとオーバーライドを適用します。
WITH days AS (
SELECT d::date AS local_date
FROM generate_series($1::date, $2::date, interval '1 day') d
), base AS (
SELECT s.id, s.tz, days.local_date,
make_timestamp(extract(year from days.local_date)::int,
extract(month from days.local_date)::int,
extract(day from days.local_date)::int,
extract(hour from s.start_time)::int,
extract(minute from s.start_time)::int, 0) AS local_start
FROM schedule s
JOIN days ON days.local_date BETWEEN s.start_date AND s.end_date
WHERE extract(dow from days.local_date)::int = ANY (s.by_dow)
)
SELECT b.id,
(b.local_start AT TIME ZONE b.tz) AS start_utc
FROM base b
LEFT JOIN schedule_skip sk
ON sk.schedule_id = b.id AND sk.local_date = b.local_date
WHERE sk.schedule_id IS NULL;
4) 表示用の変換は最後にまとめて行う
start_utc を timestamptz として保持し、ソートや衝突チェック、予約処理に使います。表示時に閲覧者のタイムゾーンへ変換することで、DST による意図しないずれを防げます。
正しいカレンダー表示を作るためのクエリパターン
カレンダースクリーンは通常「from_ts と to_ts の間を見せる」という範囲クエリです。安全なパターンは:
- そのウィンドウ内の候補だけを展開する。
- 例外/オーバーライドを適用する。
start_atとend_atをtimestamptzとして最終出力する。
generate_series を使った日次/週次の展開
単純な週ルール(例: 「毎平日ローカル09:00」)では、スケジュールのタイムゾーンでローカル日を生成し、各ローカル日 + ローカル時刻をインスタントに変換します。
-- Inputs: :from_ts, :to_ts are timestamptz
-- rule.tz is an IANA zone like 'America/New_York'
WITH bounds AS (
SELECT
(:from_ts AT TIME ZONE rule.tz)::date AS from_local_date,
(:to_ts AT TIME ZONE rule.tz)::date AS to_local_date
FROM rule
WHERE rule.id = :rule_id
), days AS (
SELECT d::date AS local_date
FROM bounds, generate_series(from_local_date, to_local_date, interval '1 day') AS g(d)
)
SELECT
(local_date + rule.start_local_time) AT TIME ZONE rule.tz AS start_at,
(local_date + rule.end_local_time) AT TIME ZONE rule.tz AS end_at
FROM rule
JOIN days ON true
WHERE EXTRACT(ISODOW FROM local_date) = ANY(rule.by_isodow);
この方法は、発生ごとに timestamptz に変換するため、その日ごとの DST 変化が正しく適用されます。
「n番目の曜日」やギャップのある複雑なルールには再帰CTE
ルールが「n番目の曜日」やギャップ、カスタム間隔に依存する場合、再帰 CTE で次の発生を繰り返して生成し、to_ts を超えたら止めます。ウィンドウに固定しておけば無限ループにはなりません。
候補行ができたら、例外・キャンセル・オーバーライドを (rule_id, start_at) かローカルキー (rule_id, local_date) で結合して適用します。キャンセルなら行を落とし、オーバーライドがあれば start_at/end_at を置き換えます。
重要なパフォーマンスパターン:
- 範囲で早めに絞る: まずルールをフィルタし、その後で範囲内だけ展開する。
- 例外/オーバーライドテーブルに
(rule_id, start_at)や(rule_id, local_date)のインデックスを貼る。 - 月表示のために年単位を展開しない。
- ルールが変わったときに確実に無効化できる場合のみ発生のキャッシュを使う。
例外とオーバーライドのきれいな扱い方
繰り返しスケジュールは、途中で安全に壊せることが重要です。予約やシフトのアプリでは「普通の週」が基本ルールで、休日やキャンセル、移動、スタッフ交代はすべて例外です。例外を後付けするとカレンダー表示がずれたり重複が発生したりします。
3つの概念を分けておきましょう:
- ベーススケジュール(再発ルールとそのタイムゾーン)
- スキップ(発生しない日やインスタンス)
- オーバーライド(存在するが詳細が変更された発生)
固定の優先順位を使う
優先順位を一つ決めて一貫させてください。一般的な選択:
- ベースの再発から候補を生成。
- オーバーライドを適用(生成を置き換える)。
- スキップを適用(非表示にする)。
ルールはユーザーに一文で説明できるようにしておくと良いです。
オーバーライドが元のインスタンスを置き換えるときの重複回避
重複は生成された発生とオーバーライド行の両方が返ることで起きます。安定したキーで防ぎます:
- 各生成インスタンスに
(schedule_id, local_date, start_time, tzid)のような安定キーを付与する。 - オーバーライド行にそのキーを「元の発生キー」として保存する。
- ベース発生ごとにオーバーライドは一つだけという一意制約を追加する。
クエリでは、オーバーライドがある生成発生を除外し、オーバーライド行を UNION で加えます。
監査性を保つ
例外は「誰が変更したか」の争点になりやすいので、skips と overrides に created_by, created_at, updated_by, updated_at、および任意の理由フィールドを追加しておくと良いです。
1時間ズレのバグを生むよくあるミス
ほとんどの1時間バグは、インスタント(UTC タイムライン上の点)とローカル時計(例えばニューヨークの毎週月曜09:00)という2つの意味を混同することから生じます。
典型的なミスは、ローカルの壁時計ルールを timestamptz として保存してしまうことです。もし「毎週月曜09:00 America/New_York」を単一の timestamptz として保存すると、既に具体的な日付(およびその時点の DST 状態)を選んでしまったことになり、将来の月曜を生成するときに「いつもローカル09:00」という意図が失われます。
もう一つの原因は固定の UTC オフセット(-05:00 など)に頼ることです。オフセットは DST ルールを含まないので不適切です。ゾーンID(例: America/New_York)を保存して、PostgreSQL に日付ごとの正しいルールを適用させてください。
変換のタイミングに注意してください。再発生成の途中で早めに UTC に変換すると、ある DST オフセットが固定されて全発生に適用されてしまう恐れがあります。安全なパターンは: 発生をローカルの(date + local time + zone)で生成し、各発生を個別にインスタントへ変換することです。
繰り返し現れるミス:
- 繰り返しのローカル時刻を保存すべきところを
timestamptzにしてしまう(本来はtime+tzid+ ルール)。 - オフセットのみを保存し、IANA ゾーンを保存していない。
- 再発生成中に早い段階で UTC に変換してしまう。
- 無制限に「永遠に」展開してしまう。
- DST 開始週と終了週をテストしていない。
ほとんどの問題を検出する簡単なテスト: DST のあるゾーンを選び、毎週09:00のシフトを作り、DST 変更をまたぐ2か月分のカレンダーをレンダリングして、各インスタンスが現地では常に09:00になっていることを確認してください(基礎となる UTC インスタンスは異なっていて構いません)。
出荷前のクイックチェックリスト
出荷前に基本を確認してください:
- すべてのスケジュールは明示的にタイムゾーンが紐づいており、スケジュール自身に保存されている。
- IANA ゾーンID(例:
America/New_York)を保存していて、生のオフセットは保存していない。 - 再発展開は要求された範囲内だけで行われる。
- 例外とオーバーライドは単一の、文書化された優先順を持つ。
- DST 変更週と、スケジュールとは別のタイムゾーンにいる閲覧者のテストを行う。
現実的なドライランを一つ: Europe/Berlin の店が毎週09:00ローカルのシフトを持ち、管理者が America/Los_Angeles から閲覧する。毎週のシフトが DST に応じても常にベルリンの09:00に留まることを確認してください。
例: 祝日と DST 変更を含む週次スタッフシフト
小さなクリニックが毎週月曜09:00〜17:00で America/New_York のローカル時間に固定された繰り返しシフトを運営しているとします。ある月曜が祝日で休診になり、スタッフの一人は2週間ヨーロッパを旅行中ですが、クリニックのスケジュールはスタッフの現在地ではなくクリニックの壁時計に紐づいたままでなければなりません。
これを正しく動かすために:
- ローカル日(曜日=月曜)とローカル時刻(09:00〜17:00)でアンカーされた再発ルールを保存する。
- スケジュールのタイムゾーン
America/New_Yorkを保存する。 - ルールに明確な起点となる開始日を保存しておく。
- 祝日のその月曜をキャンセルする例外と、単発の変更のためのオーバーライドを保存する。
2週間分のカレンダーレンジをレンダリングするとき、クエリはそのローカル日範囲の月曜を生成し、クリニックのローカル時刻を付けてから各発生を timestamptz に変換します。発生ごとに変換するため、DST は正しい日に適用されます。
異なる閲覧者は同じインスタントに対して異なる現地時計時刻を見ます:
- ロサンゼルスの管理者はより早い時計時刻で見る。
- ベルリンの旅行中のスタッフはより遅い時計時刻で見る。
しかしクリニックは常に望んだ通り: 毎週の月曜は New York 時間で09:00〜17:00(キャンセルされている日は除く)になります。
次のステップ: 実装、テスト、保守しやすくする
時間の扱いを早めに決めてください: ルールのみを保存するのか、発生のみを保存するのか、ハイブリッドにするのか。多くの予約・シフト製品ではハイブリッドがうまく機能します: ルールを真実のソースにしてローリングキャッシュを置き、例外とオーバーライドを具象の行として保存する。
「時間契約」を一箇所に書き残してください: 何がインスタントで何がローカル壁時計か、どのカラムに何を保存するか。これがあると、あるエンドポイントがローカル時刻を返し別のエンドポイントが UTC を返すといったズレを防げます。
再発生成ロジックは一つのモジュールにまとめ、散らばった SQL 断片にしないでください。将来「ローカル09:00の解釈」を変える必要が出たとき、更新箇所が一箇所で済むようにします。
手作業で全てを実装したくない場合、AppMaster (appmaster.io) はこうした作業に実用的です: Data Designer でデータベースをモデル化し、ビジネスプロセスで再発や例外ロジックを構築しつつ、本番用のバックエンドとアプリコードを生成できます。


