SQL 日付・時刻 — 月末算出・時差変換・経過時間の基礎

基礎日付・時刻月末・締め日AT TIME ZONE期間の重なりAGE / EPOCHPostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 6

月末日の算出 — 月初へ丸めてから1か月進め1日戻す

DATE_TRUNCINTERVAL月末・締め日うるう年
前提知識

月末日は月によって28〜31日と変わり、うるう年でも変わります。日付を直接組み立てるのではなく、月初へ丸めてから1か月進め、1日戻すと、どの月でも同じ式で求まります。

SELECT (DATE_TRUNC('month', date_col)
        + INTERVAL '1 month'
        - INTERVAL '1 day')::date AS month_end
FROM table_name;
-- 2026-02-03 → 2026-02-01 → 2026-03-01 → 2026-02-28
暦の知識は関数側にある:INTERVAL '1 month' は「翌月の同じ日」へ進みます。月初(1日)から進めれば必ず翌月の1日になるので、1日戻せば当月の末日です。月ごとの日数を自分で場合分けする必要はありません。
問題

billing_cycles テーブルの各行について、開始日が属する月の末日その月の日数を求めてください。取得列は cycle_id, started_on, month_end, days_in_month、started_on 昇順で返してください。

使用テーブル
▸ billing_cycles
cycle_idplanstarted_on
1standard2026-01-15
2standard2026-02-03
3premium2026-04-20
4premium2024-02-10
期待出力
cycle_idstarted_onmonth_enddays_in_month
42024-02-102024-02-2929
12026-01-152026-01-3131
22026-02-032026-02-2828
32026-04-202026-04-3030
模範解答コード
SELECT
  cycle_id,
  started_on,
  (DATE_TRUNC('month', started_on) + INTERVAL '1 month' - INTERVAL '1 day')::date AS month_end,  -- 月初 → 翌月初 → 1日戻す
  EXTRACT(DAY FROM DATE_TRUNC('month', started_on) + INTERVAL '1 month' - INTERVAL '1 day') AS days_in_month  -- 末日の「日」がその月の日数
FROM   billing_cycles
ORDER BY started_on;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM billing_cycles          → 4行読み込み
  2. SELECT 月末日・日数を計算      → 4列を出力
  3. ORDER BY started_on          → 開始日の昇順
  */
解説(テーブル変化・ポイント)
SELECT cycle_id, started_on, (DATE_TRUNC('month', started_on) + INTERVAL '1 month' - INTERVAL '1 day')::date AS month_end, EXTRACT(DAY FROM DATE_TRUNC('month', started_on) + INTERVAL '1 month' - INTERVAL '1 day') AS days_in_month FROM billing_cycles ORDER BY started_on;
LEGEND
データ取得・読込対象
① FROM billing_cycles
FROM billing_cyclesbilling_cycles 全4行を読み込みます。1月・2月・4月に加え、うるう年である2024年2月の行が含まれています。
1 / 4
cycle_idplanstarted_on
1standard2026-01-15
2standard2026-02-03
3premium2026-04-20
4premium2024-02-10
全 4行 読込
学習ポイント
月末は「翌月初の1日前」:月ごとの日数を持たなくても、月初へ丸めて1か月進め1日戻せば求まります。うるう年の判定も INTERVAL 側が持っているため、自前の閏年ルールを書く必要がありません。
末日の「日」=その月の日数:EXTRACT(DAY FROM 月末日) は 28 / 29 / 30 / 31 を返します。日割り計算の分母や、月次の平均を出すときの基準として使えます。
期間として使うなら半開区間で:その月のデータを絞り込む用途なら、末日を求めずに >= 月初 AND < 翌月初 と書くほうが安全です(Q1参照)。末日が必要なのは、締め日や請求日そのものを表示・保存するときです。
アンチパターン
月末日を CASE で場合分けする:CASE WHEN month IN (1,3,5,...) THEN 31 ... はうるう年の分岐を必ず書き漏らします。暦の規則は日付関数に任せます。
月初に足す日数で求める:月初 + 30 のような固定日数は月ごとにずれます。+ INTERVAL '1 month' は暦を理解して進むため、31日の月でも28日の月でも正しく翌月初になります。
実務コラム:「月末締め」は月末日とは限らない
請求の締め日は「毎月20日締め」「月末締め・翌月末払い」など業務ルールで決まり、暦の月末と一致しない場合があります。さらに締め日が土日祝に当たると前営業日へ繰り上げる、といった例外も付きます。SQLで求められるのは暦としての月末までで、その先は営業日カレンダーが要ります。月末日の算出をクエリへ散らさず、締め日を持つマスタか1本のビューへ集約しておくと、ルール変更が1か所で済みます。
QUESTION 7

AT TIME ZONE — UTC保存のログを日本時間の日付で集計する

TIMESTAMPTZAT TIME ZONE時差変換日付境界
前提知識

TIMESTAMPTZ 型は時点そのものを保持し、表示時にタイムゾーンへ変換されます。AT TIME ZONE は指定した地域の「壁掛け時計の時刻」へ変換し、時刻情報を持たない TIMESTAMP を返します。

SELECT tstz_col AT TIME ZONE 'Asia/Tokyo'
FROM table_name;
-- 2025-11-10 16:20:00+00(UTC) → 2025-11-11 01:20:00(JSTの壁時計)
日付の境界はタイムゾーンで動く:UTCの 15:00 以降は、日本時間ではすでに翌日です。UTCのまま日次集計すると、日本の夜間に発生した行が前日側へ寄り、日別の件数が実感とずれます。
問題

events_utc テーブルを日本時間(Asia/Tokyo)の日付で集計し、日ごとの件数を求めてください。occurred_atTIMESTAMPTZ 型でUTC表示です。取得列は jst_day, events、jst_day 昇順で返してください。

使用テーブル
▸ events_utc
event_iduser_idoccurred_at
13012026-07-01 14:30:00+00
23022026-07-01 15:30:00+00
33032026-07-01 23:10:00+00
43042026-07-02 16:00:00+00
53052026-07-02 03:45:00+00
期待出力
jst_dayevents
2026-07-011
2026-07-023
2026-07-031
模範解答コード
SELECT
  DATE_TRUNC('day', occurred_at AT TIME ZONE 'Asia/Tokyo')::date AS jst_day,  -- JSTへ変換してから日で丸める
  COUNT(*) AS events
FROM     events_utc
GROUP BY DATE_TRUNC('day', occurred_at AT TIME ZONE 'Asia/Tokyo')
ORDER BY jst_day;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM events_utc                     → 5行読み込み
  2. GROUP BY DATE_TRUNC('day', JST変換)  → 3グループに分割
  3. SELECT COUNT(*)                     → グループごとに件数
  4. ORDER BY jst_day                    → 日付の昇順
  */
解説(テーブル変化・ポイント)
SELECT DATE_TRUNC('day', occurred_at AT TIME ZONE 'Asia/Tokyo')::date AS jst_day, COUNT(*) AS events FROM events_utc GROUP BY DATE_TRUNC('day', occurred_at AT TIME ZONE 'Asia/Tokyo') ORDER BY jst_day;
LEGEND
データ取得・読込対象
① FROM events_utc
FROM events_utcevents_utc 全5行を読み込みます。occurred_at はUTC表示で、UTCの日付で見ると 07-01 が3件、07-02 が2件です。
1 / 4
event_iduser_idoccurred_at (UTC)
13012026-07-01 14:30:00+00
23022026-07-01 15:30:00+00
33032026-07-01 23:10:00+00
43042026-07-02 16:00:00+00
53052026-07-02 03:45:00+00
全 5行 読込
学習ポイント
保存はUTC、集計はローカル:時点は TIMESTAMPTZ でUTC保存し、レポートの直前に AT TIME ZONE で目的の地域へ変換するのが定石です。保存側をローカル時刻にすると、地域をまたいだ比較も夏時間の扱いもできなくなります。
変換してから丸める:順序が逆だと結果が変わります。DATE_TRUNC('day', ...) をUTCのまま適用してから変換しても、境界はUTCの0時のままです。必ず変換 → 丸めの順で書きます。
地域名で指定する:'+09' のような固定オフセットではなく 'Asia/Tokyo' のような地域名を使うと、夏時間のある地域でもその時点の正しい規則が適用されます。
アンチパターン
9時間を手で足す:occurred_at + INTERVAL '9 hours' は日本では動きますが、夏時間のある地域では年に2回ずれます。時差の規則はタイムゾーンデータベースに任せます。
セッションのタイムゾーン任せにする:接続先や実行環境によって既定のタイムゾーンが異なると、同じクエリが環境ごとに違う日別集計を返します。レポート用のクエリでは地域名を明示します。
実務コラム:日次バッチとタイムゾーンの食い違い
「昨日分を集計するバッチ」が食い違う原因の多くは、抽出条件と集計キーでタイムゾーンの前提が揃っていないことです。抽出はUTCの0時起点、表示はJSTの日付、という組み合わせだと、毎日9時間分の行が前後の日へこぼれます。抽出の境界・集計キー・画面表示の3か所で同じタイムゾーンを使うと決め、境界値をクエリのコメントに残しておくと、後からの検証が容易になります。
QUESTION 8

期間の重なり判定 — 2つの区間が交差する条件を組み立てる

期間の重なり自己結合重複予約検出境界の接触
前提知識

2つの期間 A・B が重なる条件は、Aの開始がBの終了より前、かつ Bの開始がAの終了より前です。「重なる並び方」を数え上げる必要はありません。

SELECT * FROM table_name x JOIN table_name y
  ON x.begin_col < y.end_col
 AND y.begin_col < x.end_col;
-- 終了 = 次の開始(隣接)は重なりとみなさない
隣接と重なりの境目:不等号を < にすると「10:00〜11:00」と「11:00〜12:00」は重なりません。<= にすると接触した瞬間も重なりと判定されます。予約枠のように終了時刻を含まない運用では < を使います。
問題

bookings テーブルから、同じ会議室で時間帯が重なっている予約の組を検出してください。同じ組を2回出さないよう、booking_id の小さい方を左にします。終了時刻と次の開始時刻が同じ場合は重なりとみなしません。取得列は room_id, booking_a, booking_b、booking_a・booking_b の昇順で返してください。

使用テーブル
▸ bookings
booking_idroom_idstarts_atends_at
1A2026-08-01 10:00:002026-08-01 11:00:00
2A2026-08-01 10:30:002026-08-01 11:30:00
3A2026-08-01 11:30:002026-08-01 12:30:00
4B2026-08-01 10:30:002026-08-01 11:30:00
5A2026-08-02 10:00:002026-08-02 11:00:00
期待出力
room_idbooking_abooking_b
A12
模範解答コード
SELECT
  a.room_id,
  a.booking_id AS booking_a,
  b.booking_id AS booking_b
FROM   bookings a
JOIN   bookings b
  ON   a.room_id = b.room_id           -- 同じ会議室どうしだけ比較
  AND  a.booking_id < b.booking_id  -- 同じ組の重複と自分自身を除く
  AND  a.starts_at < b.ends_at     -- 重なりの条件(前半)
  AND  b.starts_at < a.ends_at     -- 重なりの条件(後半)
ORDER BY booking_a, booking_b;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM bookings a                → 5行読み込み
  2. JOIN bookings b(同室・重なり)   → 1組に絞り込み
  3. SELECT room_id, booking_a, …   → 3列を選択
  4. ORDER BY booking_a, booking_b  → ID昇順
  */
解説(テーブル変化・ポイント)
SELECT a.room_id, a.booking_id AS booking_a, b.booking_id AS booking_b FROM bookings a JOIN bookings b ON a.room_id = b.room_id AND a.booking_id < b.booking_id AND a.starts_at < b.ends_at AND b.starts_at < a.ends_at ORDER BY booking_a, booking_b;
LEGEND
データ取得・読込対象
① FROM bookings
FROM bookings abookings 全5行を読み込みます。会議室A の4件と会議室B の1件で、日付も8月1日と8月2日に分かれています。
1 / 4
booking_idroom_idstarts_atends_at
1A2026-08-01 10:00:002026-08-01 11:00:00
2A2026-08-01 10:30:002026-08-01 11:30:00
3A2026-08-01 11:30:002026-08-01 12:30:00
4B2026-08-01 10:30:002026-08-01 11:30:00
5A2026-08-02 10:00:002026-08-02 11:00:00
全 5行 読込
学習ポイント
重なりは2つの不等号で言い切れる:「Aが先」「Bが先」「片方が内包」といった配置を場合分けする必要はありません。A.start < B.end AND B.start < A.end がすべての重なりを、そしてそれだけを表します。
組を一意にするのは ID の不等号:自己結合では a.booking_id < b.booking_id が、自分自身との比較と (1,2)/(2,1) の重複出力を同時に排除します。<> にすると結果が2倍になります。
OVERLAPS という書き方もある:PostgreSQLには (a.starts_at, a.ends_at) OVERLAPS (b.starts_at, b.ends_at) があり、同じ半開区間の意味で判定します。読みやすい一方で、境界の扱いを明示したい場面では不等号で書くほうが意図が伝わります。
アンチパターン
片方の不等号だけで判定する:a.starts_at < b.ends_at だけでは、Bが完全に過去にある組まで真になります。2つの不等号は必ず対で書きます。
BETWEEN で内包だけを調べる:b.starts_at BETWEEN a.starts_at AND a.ends_at は「Bの開始がAの中にある」場合しか捕まえられず、Bが Aを丸ごと含む重なりを見逃します。
実務コラム:重複を「検出」から「防止」へ
重なりの検出クエリは調査には有効ですが、二重予約そのものは防げません。PostgreSQLでは範囲型(tstzrange)と排他制約(EXCLUDE USING gist)を使うと、同じ部屋で重なる行の挿入をデータベース側で拒否できます。アプリケーションで「登録前に重なりを検索して、無ければ挿入する」と書くと、同時実行時に両方の検索が空振りして両方とも挿入される競合が残ります。整合性の要件が強い予約・在庫・料金期間では、制約として宣言するのが確実です。
QUESTION 9

経過時間の集計 — 時刻の差を時間数へ変換して平均を出す

EXTRACT (EPOCH)時刻の差対応時間NULL除外
前提知識

TIMESTAMP 同士の引き算は INTERVAL(期間)を返します。平均や合計を数値として扱うには、EXTRACT(EPOCH FROM ...) で秒数へ直してから単位を割ります。

SELECT EXTRACT(EPOCH FROM (end_ts - start_ts)) / 3600 AS hours
FROM table_name;
-- EPOCH は期間を秒数で返す。3600で割れば時間、60で割れば分。
未完了の行は NULL:終了時刻が入っていない行は差も NULL になります。AVG は NULL を無視しますが、COUNT(*) は NULL の行も数えるため、件数と平均の母数がずれます。対象を WHERE で明示的に絞るほうが安全です。
問題

tickets テーブルから、クローズ済みのチケットについてカテゴリ別の件数と平均対応時間(時間単位・小数第2位まで)を求めてください。取得列は category, closed_tickets, avg_hours、category 昇順で返してください。

使用テーブル
▸ tickets
ticket_idcategorycreated_atclosed_at
1billing2026-04-01 09:00:002026-04-01 12:30:00
2billing2026-04-02 10:00:002026-04-03 10:00:00
3tech2026-04-01 08:00:002026-04-01 09:30:00
4tech2026-04-05 13:00:00NULL
5tech2026-04-06 09:15:002026-04-06 11:45:00
期待出力
categoryclosed_ticketsavg_hours
billing213.75
tech22.00
模範解答コード
SELECT
  category,
  COUNT(*) AS closed_tickets,
  ROUND(AVG(EXTRACT(EPOCH FROM (closed_at - created_at)) / 3600)::numeric, 2) AS avg_hours  -- 秒 → 時間 → 平均 → 丸め
FROM     tickets
WHERE    closed_at IS NOT NULL         -- 未クローズを母数から外す
GROUP BY category
ORDER BY category;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM tickets                   → 5行読み込み
  2. WHERE closed_at IS NOT NULL    → 4行に絞り込み
  3. GROUP BY category              → 2グループに分割
  4. SELECT COUNT / AVG(EPOCH…)     → 件数と平均時間
  5. ORDER BY category              → カテゴリ昇順
  */
解説(テーブル変化・ポイント)
SELECT category, COUNT(*) AS closed_tickets, ROUND(AVG(EXTRACT(EPOCH FROM (closed_at - created_at)) / 3600)::numeric, 2) AS avg_hours FROM tickets WHERE closed_at IS NOT NULL GROUP BY category ORDER BY category;
LEGEND
データ取得・読込対象
① FROM tickets
FROM ticketstickets 全5行を読み込みます。ticket 4 は closed_at が NULL の未クローズチケットです。
1 / 5
ticket_idcategorycreated_atclosed_at
1billing2026-04-01 09:00:002026-04-01 12:30:00
2billing2026-04-02 10:00:002026-04-03 10:00:00
3tech2026-04-01 08:00:002026-04-01 09:30:00
4tech2026-04-05 13:00:00NULL
5tech2026-04-06 09:15:002026-04-06 11:45:00
全 5行 読込
学習ポイント
期間は一度数値へ直す:INTERVAL のままでも AVG は取れますが、結果が 1 day 04:30:00 のような表記になり、閾値との比較やグラフ化がしにくくなります。EPOCH で秒へ直し、目的の単位で割ってから集計します。
単位は割る数で決まる:60で割れば分、3600で割れば時間、86400で割れば日です。列名に単位を含めておく(avg_hours)と、読み手が単位を推測せずに済みます。
母数を WHERE で決める:未クローズを除外してから集計すると、COUNT(*) と平均の母数が一致します。AVG が NULL を無視する挙動に頼ると、件数だけが未完了分を含んだ数字になります。
アンチパターン
EXTRACT(HOUR FROM 差) で時間を数える:HOUR は期間の「時」の部分だけを返すため、24時間の差は 0 になります(日の部分に繰り上がるため)。経過時間の総量は必ず EPOCH から求めます。
平均だけで応答性能を語る:1件の極端に長いチケットが平均を押し上げます(ここでも24時間の1件が billing の平均を13.75時間にしています)。中央値やパーセンタイルを併記すると実態に近づきます。
実務コラム:経過時間と営業時間
サポート業務の「対応時間」は、多くの場合そのままの経過時間ではなく営業時間内の滞留時間で測ります。金曜の夕方に届いて月曜の朝に閉じたチケットは、実時間では60時間を超えますが、営業時間換算では数時間です。SLA を扱うなら、営業日・営業時間帯を持つカレンダーと突き合わせて差し引く処理が必要になります。まず素の経過時間で全体の傾向を掴み、SLA 判定の段階で営業時間へ切り替えるのが現実的な順序です。
QUESTION 10

AGE と年齢 — 誕生日を迎えたかどうかを暦に判定させる

AGEEXTRACT (YEAR)年齢計算誕生日境界
前提知識

AGE(基準日, 過去の日付) は2つの日付の差を「N年Nか月N日」という INTERVAL で返します。年の部分を取り出せば、そのまま満年齢になります。

SELECT EXTRACT(YEAR FROM AGE(DATE '2025-01-01', birth_col)) AS age
FROM table_name;
-- AGE は「年・月・日」へ繰り下げて返すため、誕生日前なら年が1つ少なくなる
年の引き算では足りない:2025 - 1990 は誕生日を迎えたかどうかを見ていないため、年内に誕生日が来ていない人の年齢が1歳多くなります。AGE は月日まで比べて繰り下げます。
問題

members テーブルについて、基準日 2026-09-01 時点の満年齢を求めてください。取得列は member_id, birth_on, age、age 降順で返してください。

使用テーブル
▸ members
member_idnamebirth_on
1佐藤1990-09-01
2鈴木1990-09-02
3高橋2000-12-31
4田中2008-08-31
期待出力
member_idbirth_onage
11990-09-0136
21990-09-0235
32000-12-3125
42008-08-3118
模範解答コード
SELECT
  member_id,
  birth_on,
  EXTRACT(YEAR FROM AGE(DATE '2026-09-01', birth_on)) AS age  -- 年・月・日の差から「年」だけを取る
FROM   members
ORDER BY age DESC;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM members                 → 4行読み込み
  2. SELECT AGE から年を取り出す    → 3列を出力
  3. ORDER BY age DESC            → 年齢の降順
  */
解説(テーブル変化・ポイント)
SELECT member_id, birth_on, EXTRACT(YEAR FROM AGE(DATE '2026-09-01', birth_on)) AS age FROM members ORDER BY age DESC;
LEGEND
データ取得・読込対象
① FROM members
FROM membersmembers 全4行を読み込みます。基準日 2026-09-01 のちょうど当日・翌日・年末・前日と、境界の並んだ誕生日です。
1 / 4
member_idnamebirth_on
1佐藤1990-09-01
2鈴木1990-09-02
3高橋2000-12-31
4田中2008-08-31
全 4行 読込
学習ポイント
AGE は月日まで比べる:返り値は「年・月・日」へ繰り下げられた INTERVAL です。誕生日をまだ迎えていなければ年が1つ少なくなるため、満年齢の定義とそのまま一致します。
基準日は引数で渡す:2引数の AGE(基準日, 誕生日) なら、過去の任意の時点での年齢も再現できます。1引数の AGE(誕生日) は実行日基準になるため、検証しづらいクエリになります。
年齢は保存せず都度求める:年齢はテーブルに持つと翌日には古くなる値です。保存するのは誕生日だけにして、年齢は必要なときに計算します(Q4 の有効期限と同じ考え方です)。
アンチパターン
年の引き算で年齢とする:EXTRACT(YEAR FROM 基準日) - EXTRACT(YEAR FROM birth_on) は誕生日前の人を1歳多く数えます。member 2・3 のように、年内に誕生日が来ていない行で必ずずれます。
日数を365で割る:(基準日 - birth_on) / 365 はうるう年の分だけ進み、高齢になるほど誤差が積み上がります。年齢の判定は暦の関数に任せます。
実務コラム:年齢の定義は国と制度で違う
日本の法律上の年齢は「誕生日の前日の満了時」に加算されるため、4月1日生まれの子どもは3月31日に歳を取り、1学年上になります。AGE が返すのは誕生日当日に加算される一般的な満年齢なので、学齢や保険料区分のように制度で定義が決まっている場面ではそのまま使えません。年齢を条件に使うクエリでは、まず「どの定義の年齢か」を仕様として確認し、必要なら基準日を1日ずらすなどの補正を明示的に書きます。