週次集計 — 週の始まりを日曜へずらして集計する
DATE_TRUNC('week', ...) の週始まりは月曜固定で、変更するオプションはありません。日曜始まりにしたいときは、いったん1日ずらして丸め、同じ量を戻します。
SELECT (DATE_TRUNC('week', date_col + INTERVAL '1 day') - INTERVAL '1 day')::date AS week_start FROM table_name; -- 日曜を1日進めると月曜になり、月曜始まりの丸めで同じ週へ入る
signups テーブルを日曜始まりの週で集計し、週の初日と登録件数を求めてください。取得列は week_start, signups、week_start 昇順で返してください。
| signup_id | channel | signed_up_on |
|---|---|---|
| 1 | web | 2026-03-01 |
| 2 | web | 2026-03-02 |
| 3 | app | 2026-03-07 |
| 4 | app | 2026-03-08 |
| 5 | web | 2026-03-09 |
| 6 | web | 2026-03-14 |
| 7 | app | 2026-03-15 |
| week_start | signups |
|---|---|
| 2026-03-01 | 3 |
| 2026-03-08 | 3 |
| 2026-03-15 | 1 |
SELECT (DATE_TRUNC('week', signed_up_on + INTERVAL '1 day') - INTERVAL '1 day')::date AS week_start, -- 進めて丸め、戻す COUNT(*) AS signups FROM signups GROUP BY (DATE_TRUNC('week', signed_up_on + INTERVAL '1 day') - INTERVAL '1 day')::date ORDER BY week_start; /* 実行順序(SQLの論理的な評価順): 1. FROM signups → 7行読み込み 2. GROUP BY ずらして丸めた週初日 → 3グループに分割 3. SELECT COUNT(*) → グループごとに件数 4. ORDER BY week_start → 週初日の昇順 */
LEGEND
① FROM signups
FROM signupssignups 全7行を読み込みます。2026-03-01 と 2026-03-08、2026-03-15 はいずれも日曜で、週の切り方が結果を変える境目です。曜日はテーブルの列ではなく、境目を見やすくするための参考表示です。| signup_id | channel | signed_up_on | 曜日 |
|---|---|---|---|
| 1 | web | 2026-03-01 | 日 |
| 2 | web | 2026-03-02 | 月 |
| 3 | app | 2026-03-07 | 土 |
| 4 | app | 2026-03-08 | 日 |
| 5 | web | 2026-03-09 | 月 |
| 6 | web | 2026-03-14 | 土 |
| 7 | app | 2026-03-15 | 日 |
INTERVAL '2 days' と、両方の数値を揃えて書き換えます。EXTRACT(WEEK ...))ではなく日付を集計キーにすると、年をまたぐ並び替えがそのまま時系列順になります。表示用の「第N週」はレポート側で付けます。EXTRACT(WEEK ...) はISO 8601の週で、月曜始まり・木曜を含む週が第1週という規則が固定されています。年末年始には前年の第52週や翌年の第1週が混ざるため、日曜始まりの業務要件は表現できません。signed_up_on - EXTRACT(DOW FROM signed_up_on) のような式は、整数と日付の演算規則に依存し読み手を選びます。丸めの規則は DATE_TRUNC に任せ、ずらす量だけを明示します。期間の按分 — 契約期間と対象月の交差日数を求める
2つの区間が交差する部分は、開始は遅い方・終了は早い方で決まります。GREATEST と LEAST を使うと、場合分けなしに交差区間の両端が書けます。
SELECT GREATEST(start_col, DATE '2025-01-01') AS from_date, -- 遅い方の開始 LEAST(end_col, DATE '2025-02-01') AS to_date -- 早い方の終了 FROM table_name;
[開始, 終了) の半開で扱うと、交差日数は 終了 - 開始 の引き算だけで求まります。+1 の補正は不要で、隣り合う期間を足しても重複しません。subscriptions テーブルの契約は [started_on, ended_on) の半開区間で、ended_on が NULL の行は継続中を表します。2026年5月に課金対象となる日数を契約ごとに求めてください。5月と1日も重ならない契約は除きます。取得列は sub_id, plan, billed_from, billed_to, billed_days、sub_id 昇順で返してください。billed_to は半開区間の終端(課金しない最初の日)とします。
| sub_id | plan | started_on | ended_on |
|---|---|---|---|
| 1 | basic | 2026-04-20 | 2026-05-10 |
| 2 | pro | 2026-05-05 | 2026-05-25 |
| 3 | pro | 2026-05-25 | NULL |
| 4 | basic | 2026-03-01 | 2026-04-15 |
| 5 | basic | 2026-04-01 | 2026-06-10 |
| sub_id | plan | billed_from | billed_to | billed_days |
|---|---|---|---|---|
| 1 | basic | 2026-05-01 | 2026-05-10 | 9 |
| 2 | pro | 2026-05-05 | 2026-05-25 | 20 |
| 3 | pro | 2026-05-25 | 2026-06-01 | 7 |
| 5 | basic | 2026-05-01 | 2026-06-01 | 31 |
SELECT sub_id, plan, GREATEST(started_on, DATE '2026-05-01') AS billed_from, -- 月初と契約開始の遅い方 LEAST(COALESCE(ended_on, DATE '2026-06-01'), DATE '2026-06-01') AS billed_to, -- 継続中は翌月初とみなす LEAST(COALESCE(ended_on, DATE '2026-06-01'), DATE '2026-06-01') - GREATEST(started_on, DATE '2026-05-01') AS billed_days -- 半開区間なので差がそのまま日数 FROM subscriptions WHERE started_on < DATE '2026-06-01' -- 5月より後に始まる契約を除外 AND COALESCE(ended_on, DATE '2026-06-01') > DATE '2026-05-01' -- 5月より前に終わる契約を除外 ORDER BY sub_id; /* 実行順序(SQLの論理的な評価順): 1. FROM subscriptions → 5行読み込み 2. WHERE 5月と重なる契約に限定 → 4行に絞り込み 3. SELECT GREATEST / LEAST → 交差区間の両端を確定 4. SELECT 終端 - 開始 → 課金日数を計算 5. ORDER BY sub_id → 契約IDの昇順 */
LEGEND
① FROM subscriptions
FROM subscriptionssubscriptions 全5行を読み込みます。5月をまたぐ契約、5月に始まる契約、終了日が NULL の継続中の契約が混ざっています。| sub_id | plan | started_on | ended_on |
|---|---|---|---|
| 1 | basic | 2026-04-20 | 2026-05-10 |
| 2 | pro | 2026-05-05 | 2026-05-25 |
| 3 | pro | 2026-05-25 | NULL |
| 4 | basic | 2026-03-01 | 2026-04-15 |
| 5 | basic | 2026-04-01 | 2026-06-10 |
GREATEST と LEAST の組で交差区間が決まります。CASE で契約の形を場合分けする必要はありません。COALESCE(ended_on, 集計期間の終端) と読み替えることで、終了済みの契約と同じ式で扱えます。終了 - 開始 がそのまま日数です。閉区間で持つと +1 の補正が必要になり、月をまたぐ合算で二重計上が起きやすくなります。GREATEST / LEAST に任せます。ended_on > DATE '2026-05-01' とだけ書くと、継続中の行は UNKNOWN で落ち、いま課金すべき契約が請求から消えます。NULL を含む列の比較は、置き換えるか IS NULL を明示します。LAG とセッション化 — 無操作30分でイベント列を区切る
LAG で1つ前の行の時刻を引き寄せると、行と行の間隔が計算できます。間隔がしきい値以上の行に 1 を立て、その累積和を取ると連番の区切りになります。ウィンドウ関数どうしは入れ子にできないため、旗を立てる階層と累積和を取る階層は分けて書きます。
SELECT SUM(is_new) OVER (PARTITION BY key_col ORDER BY ts_col) AS grp FROM ( SELECT key_col, ts_col, CASE WHEN ts_col - LAG(ts_col) OVER (PARTITION BY key_col ORDER BY ts_col) >= INTERVAL '30 minutes' THEN 1 ELSE 0 END AS is_new FROM table_name) t;
LAG は最初の行で NULL を返し、比較結果も NULL(真ではない)になります。そのため先頭行の旗は 0 のままで、累積和は 0 から始まります。app_events テーブルのイベント列を、直前のイベントから30分以上空いたところで区切ってセッションにまとめてください。セッション番号はユーザーごとに 1 から始まる連番とします。取得列は user_id, session_no, started_at, ended_at, events、user_id・session_no の昇順で返してください。
| event_id | user_id | occurred_at |
|---|---|---|
| 1 | 101 | 2026-08-10 09:00:00 |
| 2 | 101 | 2026-08-10 09:12:00 |
| 3 | 101 | 2026-08-10 10:05:00 |
| 4 | 101 | 2026-08-10 10:20:00 |
| 5 | 102 | 2026-08-10 09:30:00 |
| 6 | 102 | 2026-08-10 11:00:00 |
| user_id | session_no | started_at | ended_at | events |
|---|---|---|---|---|
| 101 | 1 | 2026-08-10 09:00:00 | 2026-08-10 09:12:00 | 2 |
| 101 | 2 | 2026-08-10 10:05:00 | 2026-08-10 10:20:00 | 2 |
| 102 | 1 | 2026-08-10 09:30:00 | 2026-08-10 09:30:00 | 1 |
| 102 | 2 | 2026-08-10 11:00:00 | 2026-08-10 11:00:00 | 1 |
WITH marked AS ( SELECT event_id, user_id, occurred_at, CASE WHEN occurred_at - LAG(occurred_at) OVER (PARTITION BY user_id ORDER BY occurred_at) >= INTERVAL '30 minutes' THEN 1 ELSE 0 END AS is_new -- 区切りに旗を立てる FROM app_events ), numbered AS ( SELECT user_id, occurred_at, SUM(is_new) OVER (PARTITION BY user_id ORDER BY occurred_at) + 1 AS session_no -- 旗の累積和が番号 FROM marked ) SELECT user_id, session_no, MIN(occurred_at) AS started_at, MAX(occurred_at) AS ended_at, COUNT(*) AS events FROM numbered GROUP BY user_id, session_no ORDER BY user_id, session_no; /* 実行順序(SQLの論理的な評価順): 1. FROM app_events → 6行読み込み 2. LAG で直前の時刻を取得 → 間隔を計算し旗を立てる 3. SUM(...) OVER で累積 → セッション番号を確定 4. GROUP BY user_id, session_no → セッション単位に集約 5. ORDER BY user_id, session_no → ユーザー・番号の昇順 */
LEGEND
① FROM app_events
FROM app_eventsapp_events 全6行を読み込みます。ユーザー101が4件、ユーザー102が2件で、いずれも時刻順に並んでいます。| event_id | user_id | occurred_at |
|---|---|---|
| 1 | 101 | 2026-08-10 09:00:00 |
| 2 | 101 | 2026-08-10 09:12:00 |
| 3 | 101 | 2026-08-10 10:05:00 |
| 4 | 101 | 2026-08-10 10:20:00 |
| 5 | 102 | 2026-08-10 09:30:00 |
| 6 | 102 | 2026-08-10 11:00:00 |
TIMESTAMP - TIMESTAMP の結果は INTERVAL なので、>= INTERVAL '30 minutes' と型を揃えて比較します。秒数へ直したいときは EXTRACT(EPOCH FROM …) を使います。PARTITION BY user_id が無いと、別ユーザーの最後のイベントとの間隔で区切りが決まってしまいます。SUM(is_new) OVER … の is_new を同一階層で定義することはできません。CTE かサブクエリで一段下げて、確定した列を参照します。移動平均のフレーム — 行数ではなく日数で7日間を切り出す
ウィンドウのフレームは行数(ROWS)でも値の範囲(RANGE)でも指定できます。ORDER BY が日付なら、RANGE の幅は INTERVAL で書けます。
SELECT AVG(num_col) OVER ( ORDER BY date_col RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW) -- 当日を含む7日間 FROM table_name;
ROWS 6 PRECEDING は「直前の6行」なので、データの無い日があると7日より長い期間を平均します。RANGE は日付そのものを見るため、行が無くても期間は7日のままです。daily_sales テーブルについて、各日の売上と当日を含む7日間の移動平均を求めてください。移動平均は実際の日付で7日間とし、行の無い日はその期間に売上が無かったものとして扱います(平均の分母には数えません)。値は小数第1位までとします。取得列は sold_on, amount, avg_7d、sold_on 昇順で返してください。
| sold_on | amount |
|---|---|
| 2026-09-01 | 100 |
| 2026-09-02 | 200 |
| 2026-09-03 | 300 |
| 2026-09-06 | 600 |
| 2026-09-07 | 700 |
| 2026-09-08 | 800 |
| sold_on | amount | avg_7d |
|---|---|---|
| 2026-09-01 | 100 | 100.0 |
| 2026-09-02 | 200 | 150.0 |
| 2026-09-03 | 300 | 200.0 |
| 2026-09-06 | 600 | 300.0 |
| 2026-09-07 | 700 | 380.0 |
| 2026-09-08 | 800 | 520.0 |
SELECT sold_on, amount, ROUND(AVG(amount) OVER ( ORDER BY sold_on RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW), 1) AS avg_7d -- 日付で7日間を切る FROM daily_sales ORDER BY sold_on; /* 実行順序(SQLの論理的な評価順): 1. FROM daily_sales → 6行読み込み 2. OVER (ORDER BY sold_on) → 日付順に並べる 3. RANGE INTERVAL '6 days' → 各行で7日間の窓を決める 4. AVG + ROUND → 窓内の平均を小数第1位へ 5. ORDER BY sold_on → 日付の昇順 */
LEGEND
① FROM daily_sales
FROM daily_salesdaily_sales 全6行を読み込みます。9月4日と9月5日は行が存在せず、日付が飛んでいます。| sold_on | amount |
|---|---|
| 2026-09-01 | 100 |
| 2026-09-02 | 200 |
| 2026-09-03 | 300 |
| 2026-09-06 | 600 |
| 2026-09-07 | 700 |
| 2026-09-08 | 800 |
RANGE ... INTERVAL が使えるのは、ORDER BY が日付・時刻型で、オフセットが加減算できる場合です。数値列なら RANGE BETWEEN 100 PRECEDING のように同じ型で書きます。ROWS BETWEEN 6 PRECEDING は、休業日で行が飛ぶ表では実質10日以上を平均することがあります。日付の意味で7日を切りたいなら RANGE を使います。LATERAL で最終既知値 — 変更履歴から各日の適用価格を復元する
LATERAL を付けたサブクエリは、左側の各行の値を参照できます。日付の土台と組み合わせると「その日時点で最後に確定していた値」を1行ずつ引けます。
SELECT d.day, x.val FROM generate_series(...) AS d(day) LEFT JOIN LATERAL ( SELECT h.val FROM history_table h WHERE h.changed_on <= d.day -- 左の行の値を参照できる ORDER BY h.changed_on DESC LIMIT 1) x ON TRUE;
WHERE 側で表現済みなので、ON には TRUE を置きます。LIMIT 1 により、左の1行に対して右は最大1行に決まります。price_changes テーブルは価格の変更があった日だけを記録した履歴です。ここから 2026-07-01 〜 2026-07-05 の5日分について、各日に適用されていた価格(その日以前で最も新しい変更後の価格)を求めてください。取得列は day, price、day 昇順で返してください。
| changed_on | price |
|---|---|
| 2026-06-20 | 1000 |
| 2026-07-02 | 1200 |
| 2026-07-04 | 900 |
| day | price |
|---|---|
| 2026-07-01 | 1000 |
| 2026-07-02 | 1200 |
| 2026-07-03 | 1200 |
| 2026-07-04 | 900 |
| 2026-07-05 | 900 |
SELECT d.day::date AS day, p.price FROM generate_series( DATE '2026-07-01', DATE '2026-07-05', INTERVAL '1 day') AS d(day) -- 日付の土台(5行) LEFT JOIN LATERAL ( SELECT c.price FROM price_changes c WHERE c.changed_on <= d.day::date -- その日までの変更だけ ORDER BY c.changed_on DESC LIMIT 1) p ON TRUE -- 最も新しい1件を採用 ORDER BY day; /* 実行順序(SQLの論理的な評価順): 1. generate_series(...) → 5日分の日付を生成 2. LATERAL サブクエリを行ごとに評価 → その日以前の最新変更を1件取得 3. LEFT JOIN ... ON TRUE → 日付へ価格を連結 4. SELECT day, price → 2列を選択 5. ORDER BY day → 日付の昇順 */
LEGEND
① generate_series で日付を生成
generate_series(DATE '2026-07-01', DATE '2026-07-05', INTERVAL '1 day')7月1日から7月5日までの5行を作ります。価格変更のあった日だけでは3行しか無いので、この土台が結果の行数を決めます。| d.day |
|---|
| 2026-07-01 |
| 2026-07-02 |
| 2026-07-03 |
| 2026-07-04 |
| 2026-07-05 |
LATERAL + ORDER BY … DESC LIMIT 1 がその標準形です。LEFT JOIN なら価格が NULL の行として残り、JOIN だとその日が結果から消えます。欠落を見えるようにしておく方が安全です。LAST_VALUE や MAX(...) OVER で埋める書き方もあります。LATERAL 版は「1行につき1件だけ読む」意図が明確で、履歴が大きいときに (changed_on) のインデックスが効きやすい形です。LIMIT 1 はどの行が返るか決まりません。最終既知値を取るなら ORDER BY changed_on DESC と、同日複数変更に備えたタイブレーク列まで書き切ります。valid_from / valid_to の2列で有効期間を持たせる設計(有効期間モデル)もあります。各日の値は day >= valid_from AND day < valid_to の単純な結合で引けるようになり、この設問のような最終既知値の探索が不要になります。代償として、変更のたびに直前行の valid_to を更新する書き込みが増え、期間の重なりや隙間を防ぐ制約が必要です。読みやすさを取るか書き込みの単純さを取るかは、更新頻度と参照頻度で決めます。