SQL 日付・時刻 — 期間指定・日付切り捨て・欠損補完の基礎

基礎日付・時刻半開区間DATE_TRUNC / EXTRACTINTERVALgenerate_seriesPostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 1

期間の絞り込み — TIMESTAMP列は半開区間で1か月を切り出す

TIMESTAMP範囲条件半開区間境界値注意
前提知識

日付列の範囲指定では、上限をどう書くかで結果が変わります。TIMESTAMP 型の列に日付リテラルだけを与えると、時刻は 00:00:00 と解釈されます。

SELECT * FROM table_name
WHERE  ts_col >= '2025-01-01'   -- 下限:含む
  AND  ts_col <  '2025-02-01';  -- 上限:含まない(翌月の始まり)
半開区間 [開始, 終了) :下限は含み、上限は含まない書き方です。BETWEEN '2025-01-01' AND '2025-01-31' は上限が 2025-01-31 00:00:00 になるため、1月31日の日中に発生した行が丸ごと落ちます。
問題

access_logs テーブルから、2026年3月に発生したアクセスを取得してください。accessed_atTIMESTAMP 型です。取得列は log_id, user_id, accessed_at、accessed_at 昇順で返してください。

使用テーブル
▸ access_logs
log_iduser_idaccessed_at
11012026-03-01 09:15:00
21022026-03-15 23:59:59
31032026-03-31 00:00:00
41042026-03-31 18:20:00
51052026-04-01 00:00:00
61062026-02-28 21:05:00
期待出力
log_iduser_idaccessed_at
11012026-03-01 09:15:00
21022026-03-15 23:59:59
31032026-03-31 00:00:00
41042026-03-31 18:20:00
模範解答コード
SELECT
  log_id, user_id, accessed_at
FROM   access_logs
WHERE  accessed_at >= '2026-03-01'    -- 下限:3月1日 00:00:00 を含む
  AND  accessed_at <  '2026-04-01'    -- 上限:4月1日 00:00:00 の直前まで
ORDER BY accessed_at;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM access_logs             → 6行読み込み
  2. WHERE 半開区間で3月を切り出し    → 4行に絞り込み
  3. SELECT log_id, user_id, ...  → 3列を選択
  4. ORDER BY accessed_at         → 時刻の昇順
  */
解説(テーブル変化・ポイント)
SELECT log_id, user_id, accessed_at FROM access_logs WHERE accessed_at >= '2026-03-01' AND accessed_at < '2026-04-01' ORDER BY accessed_at;
LEGEND
データ取得・読込対象
① FROM access_logs
FROM access_logsaccess_logs 全6行を読み込みます。3月の前後に隣接する 2026-02-28・2026-04-01 00:00:00 が含まれている点に注目してください。
1 / 3
log_iduser_idaccessed_at
11012026-03-01 09:15:00
21022026-03-15 23:59:59
31032026-03-31 00:00:00
41042026-03-31 18:20:00
51052026-04-01 00:00:00
61062026-02-28 21:05:00
全 6行 読込
学習ポイント
半開区間が基本形:期間の切り出しは >= 開始 AND < 次の期間の開始 と書きます。月末が28日でも31日でも、うるう年でも、上限の書き換えが不要になります。
DATE型とTIMESTAMP型の差:列が DATE 型なら BETWEEN '2026-03-01' AND '2026-03-31' でも正しく4行返ります。落とし穴は時刻を持つ TIMESTAMP 型のときで、同じ書き方が「月末1日分の欠落」になります。
列を裸のまま比較する:accessed_at に関数を掛けずに比較しているため、accessed_at のインデックスがそのまま使えます。
アンチパターン
上限を月末日で書く:BETWEEN '2026-03-01' AND '2026-03-31' は上限が 2026-03-31 00:00:00 と解釈され、log 4 のような月末日中の行が静かに消えます。件数が少し減るだけなので気付きにくい欠落です。
上限を 23:59:59 で書く:<= '2026-03-31 23:59:59' は秒より細かい精度(マイクロ秒)を持つ値を取りこぼします。半開区間なら精度に依存しません。
実務コラム:タイムゾーンと「その日」の境界
列が TIMESTAMPTZ 型の場合、「3月1日の始まり」は接続のタイムゾーン設定によって変わります。日本時間で集計したいなら accessed_at >= TIMESTAMPTZ '2026-03-01 00:00+09' のようにオフセットを明示するか、セッションの TimeZone を固定します。集計値がわずかにずれる不具合の多くは、この境界の解釈違いが原因です。
QUESTION 2

DATE_TRUNC — 日時を月初へ切り捨てて月次集計にまとめる

DATE_TRUNCGROUP BY月次集計時系列
前提知識

DATE_TRUNC(単位, 値) は日時を指定した単位の先頭へ切り捨てます。単位には 'year', 'month', 'week', 'day', 'hour' などを指定できます。

SELECT DATE_TRUNC('month', ts_col)::date AS bucket
FROM     table_name
GROUP BY DATE_TRUNC('month', ts_col);
-- 2026-02-18 20:45 → 2026-02-01 00:00:00 へ切り捨て
戻り値は TIMESTAMP:DATE_TRUNC('month', ...) は日付ではなく時刻付きの値を返します。表示を日付だけにしたいときは ::date でキャストします。
問題

orders テーブルを月ごとに集計し、月初日・注文件数・売上合計を求めてください。取得列は month, orders, total_amount、month 昇順で返してください。month は日付だけの表示にします。

使用テーブル
▸ orders
order_idordered_atamount
12026-01-05 10:00:003000
22026-01-22 14:30:005000
32026-02-03 09:10:004000
42026-02-18 20:45:002500
52026-02-27 08:00:001500
62026-03-09 12:00:007000
期待出力
monthorderstotal_amount
2026-01-0128000
2026-02-0138000
2026-03-0117000
模範解答コード
SELECT
  DATE_TRUNC('month', ordered_at)::date AS month,  -- 月初へ切り捨てて日付表示にする
  COUNT(*)      AS orders,
  SUM(amount)  AS total_amount
FROM     orders
GROUP BY DATE_TRUNC('month', ordered_at)   -- 集計キーは切り捨て後の値
ORDER BY month;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM orders                      → 6行読み込み
  2. GROUP BY DATE_TRUNC('month', …)  → 3グループに分割
  3. SELECT COUNT / SUM               → グループごとに集計
  4. ORDER BY month                   → 月の昇順
  */
解説(テーブル変化・ポイント)
SELECT DATE_TRUNC('month', ordered_at)::date AS month, COUNT(*) AS orders, SUM(amount) AS total_amount FROM orders GROUP BY DATE_TRUNC('month', ordered_at) ORDER BY month;
LEGEND
データ取得・読込対象
① FROM orders
FROM ordersorders 全6行を読み込みます。ordered_at は日付と時刻を持つため、このままでは1行ずつ別の値です。
1 / 4
order_idordered_atamount
12026-01-05 10:00:003000
22026-01-22 14:30:005000
32026-02-03 09:10:004000
42026-02-18 20:45:002500
52026-02-27 08:00:001500
62026-03-09 12:00:007000
全 6行 読込
学習ポイント
切り捨ては集計キーを作る操作:時刻付きの値はそのままでは全行が別グループです。DATE_TRUNC で粒度を落として初めて「月ごと」という単位が生まれます。単位を 'week''day' に替えれば同じ形のまま粒度だけ変えられます。
並び替えは切り捨てた値で:月初日で並べれば時系列順になります。TO_CHAR(ordered_at, 'YYYY年MM月') のような文字列でグループ化すると、表示は読みやすくても並び順が文字列順に引きずられ、年をまたぐ集計で崩れます。
GROUP BY には式をそのまま書く:PostgreSQLでは GROUP BY month のように出力別名を使うこともできますが、式を繰り返す形はどのDBでも通ります。
アンチパターン
WHERE 句で列に関数を掛ける:期間を絞る目的で WHERE DATE_TRUNC('month', ordered_at) = '2026-02-01' と書くと、ordered_at のインデックスが使えません。絞り込みはQ1の半開区間で行い、DATE_TRUNC は集計キーの生成に使います。
年を含めないキー:EXTRACT(MONTH FROM ordered_at) だけでグループ化すると、2025年2月と2026年2月が同じ「2」に混ざります。月次集計のキーには必ず年を含めます。
実務コラム:レポートの粒度を1か所で切り替える
日次・週次・月次を切り替えるレポートでは、DATE_TRUNC(:granularity, ordered_at) のように単位だけをパラメータ化すると、クエリ本体を1本に保てます。ただし 'week' の週始まりは月曜固定で、日曜始まりの業務要件とは合いません。その場合は DATE_TRUNC('week', ordered_at + INTERVAL '1 day') - INTERVAL '1 day' のようにずらすか、カレンダーテーブルを持たせて定義を一元管理します。
QUESTION 3

EXTRACT — 日付から曜日を取り出して曜日別に集計する

EXTRACTDOW曜日別集計0始まり注意
前提知識

EXTRACT(フィールド FROM 値) は日時から年・月・日・曜日などの構成要素を数値で取り出します。曜日は DOW(day of week)で得られます。

SELECT EXTRACT(DOW FROM date_col) AS dow
FROM   table_name;
-- DOW:    0=日 1=月 2=火 3=水 4=木 5=金 6=土
-- ISODOW: 1=月 …… 7=日(日曜が末尾)
DOW は日曜が 0 :1始まりではありません。月曜を週の先頭にしたい集計では ISODOW(1=月〜7=日)を使うと並び順がそのまま週の並びになります。
問題

reservations テーブルを曜日ごとに集計し、曜日番号・予約件数・席数合計を求めてください。曜日番号は EXTRACT(DOW ...)(0=日曜)で表します。取得列は dow, reservations, total_seats、dow 昇順で返してください。

使用テーブル
▸ reservations
reservation_idreserved_onseats
12026-06-012
22026-06-024
32026-06-066
42026-06-075
52026-06-083
62026-06-138
期待出力
dowreservationstotal_seats
015
125
214
6214
模範解答コード
SELECT
  EXTRACT(DOW FROM reserved_on) AS dow,  -- 0=日曜 … 6=土曜
  COUNT(*)     AS reservations,
  SUM(seats)  AS total_seats
FROM     reservations
GROUP BY EXTRACT(DOW FROM reserved_on)
ORDER BY dow;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM reservations           → 6行読み込み
  2. GROUP BY EXTRACT(DOW …)     → 4グループに分割
  3. SELECT COUNT / SUM          → グループごとに集計
  4. ORDER BY dow                → 曜日番号の昇順
  */
解説(テーブル変化・ポイント)
SELECT EXTRACT(DOW FROM reserved_on) AS dow, COUNT(*) AS reservations, SUM(seats) AS total_seats FROM reservations GROUP BY EXTRACT(DOW FROM reserved_on) ORDER BY dow;
LEGEND
データ取得・読込対象
① FROM reservations
FROM reservationsreservations 全6行を読み込みます。日付は2026年6月の2週間分で、同じ曜日が複数回現れます。
1 / 4
reservation_idreserved_onseats
12026-06-012
22026-06-024
32026-06-066
42026-06-075
52026-06-083
62026-06-138
全 6行 読込
学習ポイント
EXTRACT は構成要素を数値で返す:YEAR, MONTH, DAY, HOUR, DOW, ISODOW, QUARTER, EPOCH などが使えます。曜日別・時間帯別といった「周期のある切り口」を作るのが主な用途です。
DOW と ISODOW の違い:DOW は 0=日曜〜6=土曜、ISODOW は 1=月曜〜7=日曜。月曜始まりのレポートで DOW を使うと、日曜が先頭に来て並びが崩れます。
現れない曜日は行にならない:集計は元データにある値だけをグループ化します。水・木・金を 0 件として表に出したいときは、Q5 と同じく曜日の一覧側を起点に LEFT JOIN します。
アンチパターン
曜日名の文字列でグループ化する:TO_CHAR(reserved_on, 'Day') は環境の言語設定で結果が変わり、末尾に空白も入ります。並び順もアルファベット順になるため、集計キーには数値の DOW / ISODOW を使い、表示名は最後に付け替えます。
曜日を月日から自作する:日付の連番を7で割るような自前計算は、うるう年や期間の起点で狂います。曜日は必ず日付関数から取ります。
実務コラム:曜日別集計は「営業日」の定義とセット
曜日別の平均を出すとき、休業日や祝日を分母に含めるかで数字の意味が変わります。実務では祝日フラグを持つカレンダーテーブルを用意し、EXTRACT(DOW ...) の結果と突き合わせて営業日だけを対象にするのが定石です。曜日は日付から機械的に決まりますが、営業日はビジネスルールなので、データとして持たないと再現できません。
QUESTION 4

INTERVAL と日付差 — 有効期限を計算して残日数を求める

INTERVAL日付演算期限管理キャスト
前提知識

日付には期間(INTERVAL)を足し引きできます。DATE 同士の引き算は日数(整数)になります。

SELECT
  date_col + INTERVAL '1 year',   -- 結果は timestamp 型
  date_col - DATE '2025-01-01'  -- 結果は integer(日数)
FROM table_name;
型が変わる点に注意:DATE + INTERVAL の結果は TIMESTAMP 型です。日付として扱いたいときは ::date でキャストします。キャストしないまま DATE と引き算すると、結果は日数ではなく INTERVAL になります。
問題

licenses テーブルについて、発行日の1年後を有効期限とし、基準日 2026-09-01 から見た残日数を求めてください。基準日の時点で期限切れのライセンスは除外します(有効期限が基準日と同じ日のものは残日数 0 として残します)。取得列は license_id, issued_on, expires_on, days_left、days_left 昇順で返してください。

使用テーブル
▸ licenses
license_iduser_idissued_on
12012025-09-15
22022025-10-01
32032025-08-20
42042026-01-31
期待出力
license_idissued_onexpires_ondays_left
12025-09-152026-09-1514
22025-10-012026-10-0130
42026-01-312027-01-31152
模範解答コード
SELECT
  license_id,
  issued_on,
  (issued_on + INTERVAL '1 year')::date AS expires_on,  -- 1年後を日付型で
  (issued_on + INTERVAL '1 year')::date - DATE '2026-09-01' AS days_left  -- 日付同士の差は日数
FROM   licenses
WHERE  (issued_on + INTERVAL '1 year')::date >= DATE '2026-09-01'   -- 期限切れを除外
ORDER BY days_left;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM licenses                → 4行読み込み
  2. WHERE 有効期限 >= 基準日        → 3行に絞り込み
  3. SELECT 有効期限・残日数を計算    → 4列を出力
  4. ORDER BY days_left           → 残日数の昇順
  */
解説(テーブル変化・ポイント)
SELECT license_id, issued_on, (issued_on + INTERVAL '1 year')::date AS expires_on, (issued_on + INTERVAL '1 year')::date - DATE '2026-09-01' AS days_left FROM licenses WHERE (issued_on + INTERVAL '1 year')::date >= DATE '2026-09-01' ORDER BY days_left;
LEGEND
データ取得・読込対象
① FROM licenses
FROM licenseslicenses 全4行を読み込みます。テーブルが持っているのは発行日だけで、有効期限は計算で作ります。
1 / 4
license_iduser_idissued_on
12012025-09-15
22022025-10-01
32032025-08-20
42042026-01-31
全 4行 読込
学習ポイント
INTERVAL は暦を理解して足す:+ INTERVAL '1 year' は「365日後」ではなく「翌年の同じ月日」です。'1 month' も同様で、1月31日の1か月後は2月28日(うるう年なら29日)に丸められます。日数で足すと月末や年をまたぐたびにずれます。
引き算の結果は型で決まる:DATE - DATE は整数(日数)、TIMESTAMP - TIMESTAMPINTERVAL です。残日数を数値として比較・並び替えしたいなら、両辺を DATE に揃えます。
同じ式の繰り返しは整理できる:ここでは有効期限の式が3回登場します。CTE やサブクエリで1度だけ計算し、名前を付けて参照すると読みやすくなります(CTEテーマ参照)。
アンチパターン
1年を365日で近似する:issued_on + 365 はうるう年を含む期間で1日ずれます。期限や契約期間は必ず INTERVAL '1 year' のような暦単位で計算します。
基準日に CURRENT_DATE を直接埋める:実行日で結果が変わるクエリはテストも再現もできません。基準日はパラメータとして外から渡し、固定値で検証できる形にします。
実務コラム:期限切れ判定は「境界の当日」を決めてから書く
「有効期限 2026-09-15 のライセンスは、9月15日に使えるのか」は仕様の問題で、SQLの問題ではありません。当日を有効とするなら expires_on >= 基準日、当日を無効とするなら expires_on > 基準日 です。期限まわりの不具合の多くは、この1日の解釈が実装者ごとに揺れることで起きます。境界の扱いを先に文章で決め、コメントとして残しておくと、後から読む人が同じ判断を再現できます。
QUESTION 5

generate_series — 日付の連番を作って欠損日を0で埋める

generate_seriesLEFT JOIN日付補完COALESCE
前提知識

generate_series(開始, 終了, 刻み) は連続した値の並びを行として生成します。日付を刻めば、データの有無に関係なく全日付が揃った土台になります。

SELECT d.day::date
FROM   generate_series(
         DATE '2025-01-01',
         DATE '2025-01-03',
         INTERVAL '1 day') AS d(day);
-- 3行(1月1日・2日・3日)を生成する
「無い日」は集計に現れない:売上テーブルだけを集計すると、売上が0の日はそもそも行が存在しないため表から消えます。日付の一覧を左側に置いて LEFT JOIN すると、欠けた日を行として残せます。
問題

daily_sales テーブルから、2026-05-01 〜 2026-05-05 の5日分の売上を日付の抜けなく取得してください。売上のない日は 0 とします。取得列は day, amount、day 昇順で返してください。

使用テーブル
▸ daily_sales
sold_onamount
2026-05-0112000
2026-05-028000
2026-05-0515000
期待出力
dayamount
2026-05-0112000
2026-05-028000
2026-05-030
2026-05-040
2026-05-0515000
模範解答コード
SELECT
  d.day::date            AS day,
  COALESCE(s.amount, 0)  AS amount   -- 一致しない日は NULL → 0 に置換
FROM      generate_series(
            DATE '2026-05-01',
            DATE '2026-05-05',
            INTERVAL '1 day') AS d(day)   -- 日付の土台(5行)
LEFT JOIN daily_sales s
       ON s.sold_on = d.day::date              -- 土台を左、実績を右に置く
ORDER BY day;

/*
  実行順序(SQLの論理的な評価順):
  1. generate_series(...)         → 5日分の日付を生成
  2. LEFT JOIN daily_sales        → 日付へ売上を突き合わせ(無い日は NULL)
  3. SELECT COALESCE(amount, 0)   → NULL を 0 に置換
  4. ORDER BY day                 → 日付の昇順
  */
解説(テーブル変化・ポイント)
SELECT d.day::date AS day, COALESCE(s.amount, 0) AS amount FROM generate_series( DATE '2026-05-01', DATE '2026-05-05', INTERVAL '1 day') AS d(day) LEFT JOIN daily_sales s ON s.sold_on = d.day::date ORDER BY day;
LEGEND
データ取得・読込対象
① generate_series で日付を生成
generate_series(DATE '2026-05-01', DATE '2026-05-05', INTERVAL '1 day')テーブルを読む前に、5月1日から5月5日までの5行を作ります。売上の有無に関係なく、この5行が最終結果の行数を決めます。
1 / 4
d.day
2026-05-01
2026-05-02
2026-05-03
2026-05-04
2026-05-05
生成 5行
学習ポイント
行数を決めるのは左側:実績テーブルを起点にすると、データが無い日は出力にも現れません。日付の一覧を左に置いて LEFT JOIN すると、期間の長さが結果の行数を決めます。
NULL と 0 は別物:結合で一致しなかった側の列は NULL になります。「実績が無い」という意味の NULL を、レポート上の 0 へ変換するのが COALESCE の役割です。SUM は NULL を無視しますが、COUNT(*) は NULL の行も数えます。
刻みは自由:INTERVAL '1 hour' なら時間帯別、'1 month' なら月次の土台になります。同じ形のまま粒度だけを差し替えられます。
アンチパターン
LEFT JOIN の右側を WHERE で絞る:WHERE s.amount > 0 と書くと、NULL の行(5月3日・5月4日)が UNKNOWN で落ち、内部結合と同じ結果に戻ります。右表への条件は ON 句に置きます。
欠損日をアプリ側で埋める:取得後にプログラムで日付を補うと、同じ処理が画面・バッチ・CSV出力へ散らばります。日付の土台はSQL側で作り、期間の定義を1か所に保ちます。
実務コラム:generate_series とカレンダーテーブル
日付の土台を毎回 generate_series で作る方法は手軽ですが、祝日・営業日・決算期といった属性は持てません。BIやレポートを継続的に運用するなら、日付を主キーに祝日フラグや会計月を持つカレンダーテーブルを1枚用意し、全クエリがそれを起点に LEFT JOIN する形にします。「期間の定義」をデータとして共有できるため、部署ごとに月次の締め日がずれるといった食い違いを防げます。