月末日の算出 — 月初へ丸めてから1か月進め1日戻す
月末日は月によって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 昇順で返してください。
| cycle_id | plan | started_on |
|---|---|---|
| 1 | standard | 2026-01-15 |
| 2 | standard | 2026-02-03 |
| 3 | premium | 2026-04-20 |
| 4 | premium | 2024-02-10 |
| cycle_id | started_on | month_end | days_in_month |
|---|---|---|---|
| 4 | 2024-02-10 | 2024-02-29 | 29 |
| 1 | 2026-01-15 | 2026-01-31 | 31 |
| 2 | 2026-02-03 | 2026-02-28 | 28 |
| 3 | 2026-04-20 | 2026-04-30 | 30 |
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 → 開始日の昇順 */
LEGEND
① FROM billing_cycles
FROM billing_cyclesbilling_cycles 全4行を読み込みます。1月・2月・4月に加え、うるう年である2024年2月の行が含まれています。| cycle_id | plan | started_on |
|---|---|---|
| 1 | standard | 2026-01-15 |
| 2 | standard | 2026-02-03 |
| 3 | premium | 2026-04-20 |
| 4 | premium | 2024-02-10 |
INTERVAL 側が持っているため、自前の閏年ルールを書く必要がありません。EXTRACT(DAY FROM 月末日) は 28 / 29 / 30 / 31 を返します。日割り計算の分母や、月次の平均を出すときの基準として使えます。>= 月初 AND < 翌月初 と書くほうが安全です(Q1参照)。末日が必要なのは、締め日や請求日そのものを表示・保存するときです。CASE WHEN month IN (1,3,5,...) THEN 31 ... はうるう年の分岐を必ず書き漏らします。暦の規則は日付関数に任せます。月初 + 30 のような固定日数は月ごとにずれます。+ INTERVAL '1 month' は暦を理解して進むため、31日の月でも28日の月でも正しく翌月初になります。AT TIME ZONE — UTC保存のログを日本時間の日付で集計する
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の壁時計)
events_utc テーブルを日本時間(Asia/Tokyo)の日付で集計し、日ごとの件数を求めてください。occurred_at は TIMESTAMPTZ 型でUTC表示です。取得列は jst_day, events、jst_day 昇順で返してください。
| event_id | user_id | occurred_at |
|---|---|---|
| 1 | 301 | 2026-07-01 14:30:00+00 |
| 2 | 302 | 2026-07-01 15:30:00+00 |
| 3 | 303 | 2026-07-01 23:10:00+00 |
| 4 | 304 | 2026-07-02 16:00:00+00 |
| 5 | 305 | 2026-07-02 03:45:00+00 |
| jst_day | events |
|---|---|
| 2026-07-01 | 1 |
| 2026-07-02 | 3 |
| 2026-07-03 | 1 |
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 → 日付の昇順 */
LEGEND
① FROM events_utc
FROM events_utcevents_utc 全5行を読み込みます。occurred_at はUTC表示で、UTCの日付で見ると 07-01 が3件、07-02 が2件です。| event_id | user_id | occurred_at (UTC) |
|---|---|---|
| 1 | 301 | 2026-07-01 14:30:00+00 |
| 2 | 302 | 2026-07-01 15:30:00+00 |
| 3 | 303 | 2026-07-01 23:10:00+00 |
| 4 | 304 | 2026-07-02 16:00:00+00 |
| 5 | 305 | 2026-07-02 03:45:00+00 |
TIMESTAMPTZ でUTC保存し、レポートの直前に AT TIME ZONE で目的の地域へ変換するのが定石です。保存側をローカル時刻にすると、地域をまたいだ比較も夏時間の扱いもできなくなります。DATE_TRUNC('day', ...) をUTCのまま適用してから変換しても、境界はUTCの0時のままです。必ず変換 → 丸めの順で書きます。'+09' のような固定オフセットではなく 'Asia/Tokyo' のような地域名を使うと、夏時間のある地域でもその時点の正しい規則が適用されます。occurred_at + INTERVAL '9 hours' は日本では動きますが、夏時間のある地域では年に2回ずれます。時差の規則はタイムゾーンデータベースに任せます。期間の重なり判定 — 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 の昇順で返してください。
| booking_id | room_id | starts_at | ends_at |
|---|---|---|---|
| 1 | A | 2026-08-01 10:00:00 | 2026-08-01 11:00:00 |
| 2 | A | 2026-08-01 10:30:00 | 2026-08-01 11:30:00 |
| 3 | A | 2026-08-01 11:30:00 | 2026-08-01 12:30:00 |
| 4 | B | 2026-08-01 10:30:00 | 2026-08-01 11:30:00 |
| 5 | A | 2026-08-02 10:00:00 | 2026-08-02 11:00:00 |
| room_id | booking_a | booking_b |
|---|---|---|
| A | 1 | 2 |
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昇順 */
LEGEND
① FROM bookings
FROM bookings abookings 全5行を読み込みます。会議室A の4件と会議室B の1件で、日付も8月1日と8月2日に分かれています。| booking_id | room_id | starts_at | ends_at |
|---|---|---|---|
| 1 | A | 2026-08-01 10:00:00 | 2026-08-01 11:00:00 |
| 2 | A | 2026-08-01 10:30:00 | 2026-08-01 11:30:00 |
| 3 | A | 2026-08-01 11:30:00 | 2026-08-01 12:30:00 |
| 4 | B | 2026-08-01 10:30:00 | 2026-08-01 11:30:00 |
| 5 | A | 2026-08-02 10:00:00 | 2026-08-02 11:00:00 |
A.start < B.end AND B.start < A.end がすべての重なりを、そしてそれだけを表します。a.booking_id < b.booking_id が、自分自身との比較と (1,2)/(2,1) の重複出力を同時に排除します。<> にすると結果が2倍になります。(a.starts_at, a.ends_at) OVERLAPS (b.starts_at, b.ends_at) があり、同じ半開区間の意味で判定します。読みやすい一方で、境界の扱いを明示したい場面では不等号で書くほうが意図が伝わります。a.starts_at < b.ends_at だけでは、Bが完全に過去にある組まで真になります。2つの不等号は必ず対で書きます。b.starts_at BETWEEN a.starts_at AND a.ends_at は「Bの開始がAの中にある」場合しか捕まえられず、Bが Aを丸ごと含む重なりを見逃します。tstzrange)と排他制約(EXCLUDE USING gist)を使うと、同じ部屋で重なる行の挿入をデータベース側で拒否できます。アプリケーションで「登録前に重なりを検索して、無ければ挿入する」と書くと、同時実行時に両方の検索が空振りして両方とも挿入される競合が残ります。整合性の要件が強い予約・在庫・料金期間では、制約として宣言するのが確実です。経過時間の集計 — 時刻の差を時間数へ変換して平均を出す
TIMESTAMP 同士の引き算は INTERVAL(期間)を返します。平均や合計を数値として扱うには、EXTRACT(EPOCH FROM ...) で秒数へ直してから単位を割ります。
SELECT EXTRACT(EPOCH FROM (end_ts - start_ts)) / 3600 AS hours FROM table_name; -- EPOCH は期間を秒数で返す。3600で割れば時間、60で割れば分。
AVG は NULL を無視しますが、COUNT(*) は NULL の行も数えるため、件数と平均の母数がずれます。対象を WHERE で明示的に絞るほうが安全です。tickets テーブルから、クローズ済みのチケットについてカテゴリ別の件数と平均対応時間(時間単位・小数第2位まで)を求めてください。取得列は category, closed_tickets, avg_hours、category 昇順で返してください。
| ticket_id | category | created_at | closed_at |
|---|---|---|---|
| 1 | billing | 2026-04-01 09:00:00 | 2026-04-01 12:30:00 |
| 2 | billing | 2026-04-02 10:00:00 | 2026-04-03 10:00:00 |
| 3 | tech | 2026-04-01 08:00:00 | 2026-04-01 09:30:00 |
| 4 | tech | 2026-04-05 13:00:00 | NULL |
| 5 | tech | 2026-04-06 09:15:00 | 2026-04-06 11:45:00 |
| category | closed_tickets | avg_hours |
|---|---|---|
| billing | 2 | 13.75 |
| tech | 2 | 2.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 → カテゴリ昇順 */
LEGEND
① FROM tickets
FROM ticketstickets 全5行を読み込みます。ticket 4 は closed_at が NULL の未クローズチケットです。| ticket_id | category | created_at | closed_at |
|---|---|---|---|
| 1 | billing | 2026-04-01 09:00:00 | 2026-04-01 12:30:00 |
| 2 | billing | 2026-04-02 10:00:00 | 2026-04-03 10:00:00 |
| 3 | tech | 2026-04-01 08:00:00 | 2026-04-01 09:30:00 |
| 4 | tech | 2026-04-05 13:00:00 | NULL |
| 5 | tech | 2026-04-06 09:15:00 | 2026-04-06 11:45:00 |
INTERVAL のままでも AVG は取れますが、結果が 1 day 04:30:00 のような表記になり、閾値との比較やグラフ化がしにくくなります。EPOCH で秒へ直し、目的の単位で割ってから集計します。avg_hours)と、読み手が単位を推測せずに済みます。COUNT(*) と平均の母数が一致します。AVG が NULL を無視する挙動に頼ると、件数だけが未完了分を含んだ数字になります。HOUR は期間の「時」の部分だけを返すため、24時間の差は 0 になります(日の部分に繰り上がるため)。経過時間の総量は必ず EPOCH から求めます。AGE と年齢 — 誕生日を迎えたかどうかを暦に判定させる
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 降順で返してください。
| member_id | name | birth_on |
|---|---|---|
| 1 | 佐藤 | 1990-09-01 |
| 2 | 鈴木 | 1990-09-02 |
| 3 | 高橋 | 2000-12-31 |
| 4 | 田中 | 2008-08-31 |
| member_id | birth_on | age |
|---|---|---|
| 1 | 1990-09-01 | 36 |
| 2 | 1990-09-02 | 35 |
| 3 | 2000-12-31 | 25 |
| 4 | 2008-08-31 | 18 |
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 → 年齢の降順 */
LEGEND
① FROM members
FROM membersmembers 全4行を読み込みます。基準日 2026-09-01 のちょうど当日・翌日・年末・前日と、境界の並んだ誕生日です。| member_id | name | birth_on |
|---|---|---|
| 1 | 佐藤 | 1990-09-01 |
| 2 | 鈴木 | 1990-09-02 |
| 3 | 高橋 | 2000-12-31 |
| 4 | 田中 | 2008-08-31 |
INTERVAL です。誕生日をまだ迎えていなければ年が1つ少なくなるため、満年齢の定義とそのまま一致します。AGE(基準日, 誕生日) なら、過去の任意の時点での年齢も再現できます。1引数の AGE(誕生日) は実行日基準になるため、検証しづらいクエリになります。EXTRACT(YEAR FROM 基準日) - EXTRACT(YEAR FROM birth_on) は誕生日前の人を1歳多く数えます。member 2・3 のように、年内に誕生日が来ていない行で必ずずれます。(基準日 - birth_on) / 365 はうるう年の分だけ進み、高齢になるほど誤差が積み上がります。年齢の判定は暦の関数に任せます。AGE が返すのは誕生日当日に加算される一般的な満年齢なので、学齢や保険料区分のように制度で定義が決まっている場面ではそのまま使えません。年齢を条件に使うクエリでは、まず「どの定義の年齢か」を仕様として確認し、必要なら基準日を1日ずらすなどの補正を明示的に書きます。