期間の絞り込み — TIMESTAMP列は半開区間で1か月を切り出す
日付列の範囲指定では、上限をどう書くかで結果が変わります。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_at は TIMESTAMP 型です。取得列は log_id, user_id, accessed_at、accessed_at 昇順で返してください。
| log_id | user_id | accessed_at |
|---|---|---|
| 1 | 101 | 2026-03-01 09:15:00 |
| 2 | 102 | 2026-03-15 23:59:59 |
| 3 | 103 | 2026-03-31 00:00:00 |
| 4 | 104 | 2026-03-31 18:20:00 |
| 5 | 105 | 2026-04-01 00:00:00 |
| 6 | 106 | 2026-02-28 21:05:00 |
| log_id | user_id | accessed_at |
|---|---|---|
| 1 | 101 | 2026-03-01 09:15:00 |
| 2 | 102 | 2026-03-15 23:59:59 |
| 3 | 103 | 2026-03-31 00:00:00 |
| 4 | 104 | 2026-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 → 時刻の昇順 */
LEGEND
① FROM access_logs
FROM access_logsaccess_logs 全6行を読み込みます。3月の前後に隣接する 2026-02-28・2026-04-01 00:00:00 が含まれている点に注目してください。| log_id | user_id | accessed_at |
|---|---|---|
| 1 | 101 | 2026-03-01 09:15:00 |
| 2 | 102 | 2026-03-15 23:59:59 |
| 3 | 103 | 2026-03-31 00:00:00 |
| 4 | 104 | 2026-03-31 18:20:00 |
| 5 | 105 | 2026-04-01 00:00:00 |
| 6 | 106 | 2026-02-28 21:05:00 |
>= 開始 AND < 次の期間の開始 と書きます。月末が28日でも31日でも、うるう年でも、上限の書き換えが不要になります。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 のような月末日中の行が静かに消えます。件数が少し減るだけなので気付きにくい欠落です。<= '2026-03-31 23:59:59' は秒より細かい精度(マイクロ秒)を持つ値を取りこぼします。半開区間なら精度に依存しません。TIMESTAMPTZ 型の場合、「3月1日の始まり」は接続のタイムゾーン設定によって変わります。日本時間で集計したいなら accessed_at >= TIMESTAMPTZ '2026-03-01 00:00+09' のようにオフセットを明示するか、セッションの TimeZone を固定します。集計値がわずかにずれる不具合の多くは、この境界の解釈違いが原因です。DATE_TRUNC — 日時を月初へ切り捨てて月次集計にまとめる
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 へ切り捨て
DATE_TRUNC('month', ...) は日付ではなく時刻付きの値を返します。表示を日付だけにしたいときは ::date でキャストします。orders テーブルを月ごとに集計し、月初日・注文件数・売上合計を求めてください。取得列は month, orders, total_amount、month 昇順で返してください。month は日付だけの表示にします。
| order_id | ordered_at | amount |
|---|---|---|
| 1 | 2026-01-05 10:00:00 | 3000 |
| 2 | 2026-01-22 14:30:00 | 5000 |
| 3 | 2026-02-03 09:10:00 | 4000 |
| 4 | 2026-02-18 20:45:00 | 2500 |
| 5 | 2026-02-27 08:00:00 | 1500 |
| 6 | 2026-03-09 12:00:00 | 7000 |
| month | orders | total_amount |
|---|---|---|
| 2026-01-01 | 2 | 8000 |
| 2026-02-01 | 3 | 8000 |
| 2026-03-01 | 1 | 7000 |
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 → 月の昇順 */
LEGEND
① FROM orders
FROM ordersorders 全6行を読み込みます。ordered_at は日付と時刻を持つため、このままでは1行ずつ別の値です。| order_id | ordered_at | amount |
|---|---|---|
| 1 | 2026-01-05 10:00:00 | 3000 |
| 2 | 2026-01-22 14:30:00 | 5000 |
| 3 | 2026-02-03 09:10:00 | 4000 |
| 4 | 2026-02-18 20:45:00 | 2500 |
| 5 | 2026-02-27 08:00:00 | 1500 |
| 6 | 2026-03-09 12:00:00 | 7000 |
DATE_TRUNC で粒度を落として初めて「月ごと」という単位が生まれます。単位を 'week' や 'day' に替えれば同じ形のまま粒度だけ変えられます。TO_CHAR(ordered_at, 'YYYY年MM月') のような文字列でグループ化すると、表示は読みやすくても並び順が文字列順に引きずられ、年をまたぐ集計で崩れます。GROUP BY month のように出力別名を使うこともできますが、式を繰り返す形はどのDBでも通ります。WHERE DATE_TRUNC('month', ordered_at) = '2026-02-01' と書くと、ordered_at のインデックスが使えません。絞り込みはQ1の半開区間で行い、DATE_TRUNC は集計キーの生成に使います。EXTRACT(MONTH FROM ordered_at) だけでグループ化すると、2025年2月と2026年2月が同じ「2」に混ざります。月次集計のキーには必ず年を含めます。DATE_TRUNC(:granularity, ordered_at) のように単位だけをパラメータ化すると、クエリ本体を1本に保てます。ただし 'week' の週始まりは月曜固定で、日曜始まりの業務要件とは合いません。その場合は DATE_TRUNC('week', ordered_at + INTERVAL '1 day') - INTERVAL '1 day' のようにずらすか、カレンダーテーブルを持たせて定義を一元管理します。EXTRACT — 日付から曜日を取り出して曜日別に集計する
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=日(日曜が末尾)
ISODOW(1=月〜7=日)を使うと並び順がそのまま週の並びになります。reservations テーブルを曜日ごとに集計し、曜日番号・予約件数・席数合計を求めてください。曜日番号は EXTRACT(DOW ...)(0=日曜)で表します。取得列は dow, reservations, total_seats、dow 昇順で返してください。
| reservation_id | reserved_on | seats |
|---|---|---|
| 1 | 2026-06-01 | 2 |
| 2 | 2026-06-02 | 4 |
| 3 | 2026-06-06 | 6 |
| 4 | 2026-06-07 | 5 |
| 5 | 2026-06-08 | 3 |
| 6 | 2026-06-13 | 8 |
| dow | reservations | total_seats |
|---|---|---|
| 0 | 1 | 5 |
| 1 | 2 | 5 |
| 2 | 1 | 4 |
| 6 | 2 | 14 |
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 → 曜日番号の昇順 */
LEGEND
① FROM reservations
FROM reservationsreservations 全6行を読み込みます。日付は2026年6月の2週間分で、同じ曜日が複数回現れます。| reservation_id | reserved_on | seats |
|---|---|---|
| 1 | 2026-06-01 | 2 |
| 2 | 2026-06-02 | 4 |
| 3 | 2026-06-06 | 6 |
| 4 | 2026-06-07 | 5 |
| 5 | 2026-06-08 | 3 |
| 6 | 2026-06-13 | 8 |
YEAR, MONTH, DAY, HOUR, DOW, ISODOW, QUARTER, EPOCH などが使えます。曜日別・時間帯別といった「周期のある切り口」を作るのが主な用途です。DOW は 0=日曜〜6=土曜、ISODOW は 1=月曜〜7=日曜。月曜始まりのレポートで DOW を使うと、日曜が先頭に来て並びが崩れます。LEFT JOIN します。TO_CHAR(reserved_on, 'Day') は環境の言語設定で結果が変わり、末尾に空白も入ります。並び順もアルファベット順になるため、集計キーには数値の DOW / ISODOW を使い、表示名は最後に付け替えます。EXTRACT(DOW ...) の結果と突き合わせて営業日だけを対象にするのが定石です。曜日は日付から機械的に決まりますが、営業日はビジネスルールなので、データとして持たないと再現できません。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 昇順で返してください。
| license_id | user_id | issued_on |
|---|---|---|
| 1 | 201 | 2025-09-15 |
| 2 | 202 | 2025-10-01 |
| 3 | 203 | 2025-08-20 |
| 4 | 204 | 2026-01-31 |
| license_id | issued_on | expires_on | days_left |
|---|---|---|---|
| 1 | 2025-09-15 | 2026-09-15 | 14 |
| 2 | 2025-10-01 | 2026-10-01 | 30 |
| 4 | 2026-01-31 | 2027-01-31 | 152 |
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 → 残日数の昇順 */
LEGEND
① FROM licenses
FROM licenseslicenses 全4行を読み込みます。テーブルが持っているのは発行日だけで、有効期限は計算で作ります。| license_id | user_id | issued_on |
|---|---|---|
| 1 | 201 | 2025-09-15 |
| 2 | 202 | 2025-10-01 |
| 3 | 203 | 2025-08-20 |
| 4 | 204 | 2026-01-31 |
+ INTERVAL '1 year' は「365日後」ではなく「翌年の同じ月日」です。'1 month' も同様で、1月31日の1か月後は2月28日(うるう年なら29日)に丸められます。日数で足すと月末や年をまたぐたびにずれます。DATE - DATE は整数(日数)、TIMESTAMP - TIMESTAMP は INTERVAL です。残日数を数値として比較・並び替えしたいなら、両辺を DATE に揃えます。issued_on + 365 はうるう年を含む期間で1日ずれます。期限や契約期間は必ず INTERVAL '1 year' のような暦単位で計算します。expires_on >= 基準日、当日を無効とするなら expires_on > 基準日 です。期限まわりの不具合の多くは、この1日の解釈が実装者ごとに揺れることで起きます。境界の扱いを先に文章で決め、コメントとして残しておくと、後から読む人が同じ判断を再現できます。generate_series — 日付の連番を作って欠損日を0で埋める
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日)を生成する
LEFT JOIN すると、欠けた日を行として残せます。daily_sales テーブルから、2026-05-01 〜 2026-05-05 の5日分の売上を日付の抜けなく取得してください。売上のない日は 0 とします。取得列は day, amount、day 昇順で返してください。
| sold_on | amount |
|---|---|
| 2026-05-01 | 12000 |
| 2026-05-02 | 8000 |
| 2026-05-05 | 15000 |
| day | amount |
|---|---|
| 2026-05-01 | 12000 |
| 2026-05-02 | 8000 |
| 2026-05-03 | 0 |
| 2026-05-04 | 0 |
| 2026-05-05 | 15000 |
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 → 日付の昇順 */
LEGEND
① generate_series で日付を生成
generate_series(DATE '2026-05-01', DATE '2026-05-05', INTERVAL '1 day')テーブルを読む前に、5月1日から5月5日までの5行を作ります。売上の有無に関係なく、この5行が最終結果の行数を決めます。| d.day |
|---|
| 2026-05-01 |
| 2026-05-02 |
| 2026-05-03 |
| 2026-05-04 |
| 2026-05-05 |
LEFT JOIN すると、期間の長さが結果の行数を決めます。0 へ変換するのが COALESCE の役割です。SUM は NULL を無視しますが、COUNT(*) は NULL の行も数えます。INTERVAL '1 hour' なら時間帯別、'1 month' なら月次の土台になります。同じ形のまま粒度だけを差し替えられます。WHERE s.amount > 0 と書くと、NULL の行(5月3日・5月4日)が UNKNOWN で落ち、内部結合と同じ結果に戻ります。右表への条件は ON 句に置きます。generate_series で作る方法は手軽ですが、祝日・営業日・決算期といった属性は持てません。BIやレポートを継続的に運用するなら、日付を主キーに祝日フラグや会計月を持つカレンダーテーブルを1枚用意し、全クエリがそれを起点に LEFT JOIN する形にします。「期間の定義」をデータとして共有できるため、部署ごとに月次の締め日がずれるといった食い違いを防げます。