- データベース
PostgreSQL date_truncとintervalで月初・月末・差分
date_trunc とは、日時を「月」「週」「時」など指定した単位で切り捨てる PostgreSQL の関数で、interval は「1 month」「3 days」のような期間を表す型です。 この2つを組み合わせると、月初・月末・前月・翌月・週初・四半期初・年度初といった「区切りの日付」を、短い式で安全に求められます。
この記事では、PostgreSQL 16.15 で実際に実行した結果をそのまま載せながら、date_trunc の全13単位、interval の加算・減算、月末の丸め、日付の差分(引き算・age・extract)、タイムゾーン指定、date_bin、月別集計とインデックスが効く書き方までを順に整理します。基準日はすべて 2026-10-03(土曜日)、セッションのタイムゾーンは Asia/Tokyo です。
date_trunc と interval の早見表
まず、いちばん使う式をまとめます。d は date 型または timestamp 型の列・値です。
| やりたいこと | 式 | 2026-10-03 での結果 |
|---|---|---|
| 月初 | date_trunc('month', d)::date |
2026-10-01 |
| 月末 | (date_trunc('month', d) + INTERVAL '1 month' - INTERVAL '1 day')::date |
2026-10-31 |
| 前月の月初 | (date_trunc('month', d) - INTERVAL '1 month')::date |
2026-09-01 |
| 前月の月末 | (date_trunc('month', d) - INTERVAL '1 day')::date |
2026-09-30 |
| 翌月の月初 | (date_trunc('month', d) + INTERVAL '1 month')::date |
2026-11-01 |
| 翌月の月末 | (date_trunc('month', d) + INTERVAL '2 month' - INTERVAL '1 day')::date |
2026-11-30 |
| 週初(月曜) | date_trunc('week', d)::date |
2026-09-28 |
| 週初(日曜) | (date_trunc('week', d + 1) - INTERVAL '1 day')::date |
2026-09-27 |
| 四半期初 | date_trunc('quarter', d)::date |
2026-10-01 |
| 年初 | date_trunc('year', d)::date |
2026-01-01 |
| 年度初(4月始まり) | (date_trunc('year', d - INTERVAL '3 months') + INTERVAL '3 months')::date |
2026-04-01 |
| 日数の差 | DATE '2026-10-03' - DATE '2026-01-31' |
245(integer) |
| 年月日での差 | age(DATE '2026-10-03', DATE '2024-02-29') |
2 years 7 mons 3 days |
| N 日後 | d + 7 または d + INTERVAL '7 days' |
2026-10-10 |
| 1か月後 | (d + INTERVAL '1 month')::date |
2026-11-03 |
date 型に date_trunc をかけると結果は timestamp with time zone になるため、日付だけが欲しいときは末尾に ::date を付けます(理由は後述します)。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「PostgreSQL date_truncとintervalで月初・月末・差分」
検証した環境と前提
| 項目 | 内容 |
|---|---|
| PostgreSQL | 16.15(Ubuntu 24.04 のパッケージ) |
| クライアント | psql |
| セッションのタイムゾーン | SET TIME ZONE 'Asia/Tokyo'; |
| 基準日 | 2026-10-03(土曜日) |
| 仕様の確認 | PostgreSQL 公式ドキュメント「Date/Time Functions and Operators」(開いた時点の表示は PostgreSQL 18.6) |
date_trunc の第3引数(タイムゾーン)は PostgreSQL 12 以降、date_bin は PostgreSQL 14 以降で使える機能として一般に知られています。今回は各バージョンのリリースノートまでは開いておらず、16.15 で動作することを確認しています。
date_trunc の基本と全13単位の実行結果
構文は次の形です。第1引数の単位は文字列で渡します。
date_trunc(単位, 日時 [, タイムゾーン])
公式ドキュメントに載っている13単位を、1つの値に対してまとめて実行しました。
SELECT u AS unit,
date_trunc(u, TIMESTAMP '2026-10-03 14:25:36.123456') AS result
FROM unnest(ARRAY['microseconds','milliseconds','second','minute','hour','day',
'week','month','quarter','year','decade','century','millennium']) AS u;
unit | result
--------------+----------------------------
microseconds | 2026-10-03 14:25:36.123456
milliseconds | 2026-10-03 14:25:36.123
second | 2026-10-03 14:25:36
minute | 2026-10-03 14:25:00
hour | 2026-10-03 14:00:00
day | 2026-10-03 00:00:00
week | 2026-09-28 00:00:00
month | 2026-10-01 00:00:00
quarter | 2026-10-01 00:00:00
year | 2026-01-01 00:00:00
decade | 2020-01-01 00:00:00
century | 2001-01-01 00:00:00
millennium | 2001-01-01 00:00:00
(13 rows)
week はその週の月曜日(ISO 8601 の週)、decade は年の下1桁を0にした年、century と millennium は「01年」から始まる数え方で切り捨てられます。
戻り値の型は入力の型で決まる
date_trunc の戻り値は date 型になりません。 入力ごとの戻り値を pg_typeof で確かめた結果です。
SELECT pg_typeof(date_trunc('month', DATE '2026-10-03')) AS from_date,
pg_typeof(date_trunc('month', TIMESTAMP '2026-10-03 14:25:36')) AS from_ts,
pg_typeof(date_trunc('month', TIMESTAMPTZ '2026-10-03 14:25:36+09')) AS from_tstz,
pg_typeof(date_trunc('hour', INTERVAL '2 days 3 hours 40 minutes')) AS from_interval;
from_date | from_ts | from_tstz | from_interval
--------------------------+-----------------------------+--------------------------+---------------
timestamp with time zone | timestamp without time zone | timestamp with time zone | interval
| 入力の型 | 戻り値の型 | 補足 |
|---|---|---|
| date | timestamp with time zone | date が暗黙に timestamptz へ変換される。::date で戻す |
| timestamp | timestamp | タイムゾーンの影響を受けない |
| timestamptz | timestamptz | セッションまたは第3引数のタイムゾーンで切り捨てる |
| interval | interval | 2 days 03:40:00 を hour で切ると 2 days 03:00:00 |
SELECT date_trunc('month', DATE '2026-10-03') AS d,
date_trunc('month', DATE '2026-10-03')::date AS d_cast;
d | d_cast
------------------------+------------
2026-10-01 00:00:00+09 | 2026-10-01
画面や帳票に出すときに 00:00:00+09 が付いて見えるのは、この型変換によるものです。日付として扱いたいときは ::date を付ける、と決めておくと迷いません。
単位の書き方でつまずく例
単位に対応していない文字列を渡したとき、引用符を付け忘れたときのエラー文です。
SELECT date_trunc('fortnight', now());
SELECT date_trunc(month, now());
ERROR: unit "fortnight" not recognized for type timestamp with time zone
ERROR: column "month" does not exist
LINE 1: SELECT date_trunc(month, now());
^
2つ目は month を列名として解釈した結果です。単位は必ずシングルクォートで囲みます。
interval で日付を加算・減算する
interval は期間を表す型で、INTERVAL '1 month' のように書きます。日時に足したり引いたりできます。
date に足す:整数と interval で戻り値の型が変わる
SELECT DATE '2026-10-03' + 7 AS plus_int,
DATE '2026-10-03' - 7 AS minus_int,
DATE '2026-10-03' + INTERVAL '1 day' AS plus_1day,
(DATE '2026-10-03' + INTERVAL '1 month')::date AS plus_1mon,
(DATE '2026-10-03' - INTERVAL '1 year')::date AS minus_1year;
plus_int | minus_int | plus_1day | plus_1mon | minus_1year
------------+------------+---------------------+------------+-------------
2026-10-10 | 2026-09-26 | 2026-10-04 00:00:00 | 2026-11-03 | 2025-10-03
| 式 | 戻り値の型 | 向いている場面 |
|---|---|---|
date + 整数 |
date | 日数だけを動かすとき。型が変わらず扱いやすい |
date + interval |
timestamp | 月・年・時間を動かすとき。日付に戻すなら ::date |
timestamp + interval |
timestamp | 時刻を含む計算 |
timestamptz + interval |
timestamptz | タイムゾーンを持つ日時の計算 |
「日数だけ動かすなら整数、月や年を動かすなら interval」 と使い分けると、キャストの回数が減ります。
timestamp に足す:いろいろな書き方
SELECT TIMESTAMP '2026-10-03 14:25:36' + INTERVAL '90 minutes' AS plus_90min,
TIMESTAMP '2026-10-03 14:25:36' + INTERVAL '1 week' AS plus_1week,
TIMESTAMP '2026-10-03 14:25:36' - INTERVAL '1 month 2 days 3 hours' AS minus_mix,
TIMESTAMP '2026-10-03 14:25:36' + 3 * INTERVAL '1 hour' AS times3,
TIMESTAMP '2026-10-03 14:25:36' + make_interval(days => 10) AS mk;
plus_90min | plus_1week | minus_mix | times3 | mk
---------------------+---------------------+---------------------+---------------------+---------------------
2026-10-03 15:55:36 | 2026-10-10 14:25:36 | 2026-09-01 11:25:36 | 2026-10-03 17:25:36 | 2026-10-13 14:25:36
アプリケーションから「N か月後」を渡すときは、$1 * INTERVAL '1 month' や make_interval(months => $1) の形にすると、文字列連結をせずにパラメータを渡せます。
interval を書くときにつまずく例
SELECT DATE '2026-10-03' + '1 month';
SELECT DATE '2026-10-03' + DATE '2026-01-01';
ERROR: operator is not unique: date + unknown
HINT: Could not choose a best candidate operator. You might need to add explicit type casts.
ERROR: operator does not exist: date + date
HINT: No operator matches the given name and argument types. You might need to add explicit type casts.
1つ目は右辺の型が決まらないためで、INTERVAL '1 month' と型を書くと通ります。2つ目は日付どうしの足し算が定義されていないためです。
月初・月末・前月・翌月のコピペ集
基準日を1か所に書けば動く形にしました。DATE '2026-10-03' を current_date や列名に置き換えて使えます。
SELECT date_trunc('month', d)::date AS month_start,
(date_trunc('month', d) + INTERVAL '1 month' - INTERVAL '1 day')::date AS month_end,
(date_trunc('month', d) - INTERVAL '1 month')::date AS prev_month_start,
(date_trunc('month', d) - INTERVAL '1 day')::date AS prev_month_end,
(date_trunc('month', d) + INTERVAL '1 month')::date AS next_month_start,
(date_trunc('month', d) + INTERVAL '2 month' - INTERVAL '1 day')::date AS next_month_end
FROM (VALUES (DATE '2026-10-03')) AS t(d);
month_start | month_end | prev_month_start | prev_month_end | next_month_start | next_month_end
-------------+------------+------------------+----------------+------------------+----------------
2026-10-01 | 2026-10-31 | 2026-09-01 | 2026-09-30 | 2026-11-01 | 2026-11-30
月末は「月初に1か月足してから1日引く」 のが基本形です。翌月1日の前日を求めるので、28日・29日・30日・31日のどの月でも正しい日付になります。
今日を基準にするなら d を current_date に置き換えます。月末は (date_trunc('month', current_date) + INTERVAL '1 month - 1 day')::date とも書け、2026-10-03 に実行して 2026-10-31 になることを確認しました。
INTERVAL '1 month - 1 day' は 1 mon -1 days という1つの interval になります。月初に足す場合は2つに分けた書き方と同じ結果です。ただし月初以外の日付に足すと結果が変わる場合があるため(次の節で確認します)、必ず date_trunc で月初にしてから足す形で使います。
うるう年の2月末も同じ式で求まる
SELECT y, (make_date(y, 2, 1) + INTERVAL '1 month' - INTERVAL '1 day')::date AS feb_end
FROM (VALUES (2024), (2026), (2028), (2100)) AS t(y);
y | feb_end
------+------------
2024 | 2024-02-29
2026 | 2026-02-28
2028 | 2028-02-29
2100 | 2100-02-28
2100年は100で割り切れて400で割り切れないので平年です。うるう年の判定を自分で書かなくても、月初 + 1 month - 1 day で正しい月末が出ます。
週初・四半期初・年初・年度初のコピペ集
SELECT date_trunc('week', d)::date AS week_start_mon,
(date_trunc('week', d) + INTERVAL '6 days')::date AS week_end_sun,
(date_trunc('week', d + 1) - INTERVAL '1 day')::date AS week_start_sun,
date_trunc('quarter', d)::date AS quarter_start,
(date_trunc('quarter', d) + INTERVAL '3 months' - INTERVAL '1 day')::date AS quarter_end,
date_trunc('year', d)::date AS year_start,
(date_trunc('year', d) + INTERVAL '1 year' - INTERVAL '1 day')::date AS year_end,
(date_trunc('year', d - INTERVAL '3 months') + INTERVAL '3 months')::date AS fiscal_start_apr
FROM (VALUES (DATE '2026-10-03')) AS t(d);
week_start_mon | week_end_sun | week_start_sun | quarter_start | quarter_end | year_start | year_end | fiscal_start_apr
----------------+--------------+----------------+---------------+-------------+------------+------------+------------------
2026-09-28 | 2026-10-04 | 2026-09-27 | 2026-10-01 | 2026-12-31 | 2026-01-01 | 2026-12-31 | 2026-04-01
週の始まりを日曜にする
date_trunc('week', ...) は月曜始まりです。日曜始まりにしたいときは、1日進めてから切り捨て、1日戻します。 土・日・月の3日で確かめました。
SELECT d, to_char(d, 'Dy') AS dow,
date_trunc('week', d)::date AS mon_start,
(date_trunc('week', d + 1) - INTERVAL '1 day')::date AS sun_start
FROM (VALUES (DATE '2026-10-03'), (DATE '2026-10-04'), (DATE '2026-10-05')) AS t(d);
d | dow | mon_start | sun_start
------------+-----+------------+------------
2026-10-03 | Sat | 2026-09-28 | 2026-09-27
2026-10-04 | Sun | 2026-09-28 | 2026-10-04
2026-10-05 | Mon | 2026-10-05 | 2026-10-04
日曜日(10月4日)は、月曜始まりでは「前の週の最終日」、日曜始まりでは「新しい週の初日」になっていることがわかります。
年度(4月始まり)の初日と年度の番号
4月始まりの年度は、3か月引いてから year で切り捨て、3か月足すと求まります。年度の境目の日付で確認しました。
SELECT d,
(date_trunc('year', d - INTERVAL '3 months') + INTERVAL '3 months')::date AS fiscal_start,
extract(year FROM d - INTERVAL '3 months')::int AS fiscal_year
FROM (VALUES (DATE '2026-03-31'), (DATE '2026-04-01'),
(DATE '2026-10-03'), (DATE '2027-01-15')) AS t(d);
d | fiscal_start | fiscal_year
------------+--------------+-------------
2026-03-31 | 2025-04-01 | 2025
2026-04-01 | 2026-04-01 | 2026
2026-10-03 | 2026-04-01 | 2026
2027-01-15 | 2026-04-01 | 2026
10月始まりなら「9か月引いて、9か月足す」と、ずらす月数を変えるだけで応用できます。
1月31日 + 1 month はどうなるか(月末の丸め)
月を足した結果の日がその月に存在しないときは、その月の最終日に丸められます。 公式ドキュメントにも「3月31日 + 1か月は4月30日、3月31日 + 2か月は5月31日」と書かれています。実際の結果です。
SELECT d,
(d + INTERVAL '1 month')::date AS plus_1m,
(d + INTERVAL '2 months')::date AS plus_2m,
(d + INTERVAL '1 month' + INTERVAL '1 month')::date AS plus_1m_twice,
(d - INTERVAL '1 month')::date AS minus_1m
FROM (VALUES (DATE '2026-01-31'), (DATE '2028-01-31'), (DATE '2026-03-31'),
(DATE '2026-05-31'), (DATE '2028-02-29')) AS t(d);
d | plus_1m | plus_2m | plus_1m_twice | minus_1m
------------+------------+------------+---------------+------------
2026-01-31 | 2026-02-28 | 2026-03-31 | 2026-03-28 | 2025-12-31
2028-01-31 | 2028-02-29 | 2028-03-31 | 2028-03-29 | 2027-12-31
2026-03-31 | 2026-04-30 | 2026-05-31 | 2026-05-30 | 2026-02-28
2026-05-31 | 2026-06-30 | 2026-07-31 | 2026-07-30 | 2026-04-30
2028-02-29 | 2028-03-29 | 2028-04-29 | 2028-04-29 | 2028-01-29
この表から読み取れることを整理します。
| 確認できたこと | 例 | 対応 |
|---|---|---|
| 存在しない日は月末に丸められる | 1月31日 + 1 month = 2月28日(うるう年は2月29日) | 仕様どおり。エラーにはならない |
| 「+2か月」と「+1か月を2回」は結果が違う | 1月31日 → 3月31日 と 3月28日 | 起点の日付に、合計の月数を一度に足す |
| 月末から月末へは自動で揃わない | 2月29日 + 1 month = 3月29日 | 月末が欲しいときは月初経由の式を使う |
| 足して引いても元に戻らないことがある | 1月31日 + 1 month - 1 month = 1月28日 | 途中結果を持ち回らず、元の日付から計算する |
2月29日に1年足した場合も同じ考え方で、DATE '2028-02-29' + INTERVAL '1 year' は 2029-02-28 になりました。
毎月の同じ日を並べるときの書き方
「毎月末日の一覧」「契約開始日から N か月後の一覧」を作るときは、起点に n * INTERVAL '1 month' を足す形にすると、丸めが積み重なりません。
-- 起点から n か月後(丸めは毎回、起点の日を基準に行われる)
SELECT n, (DATE '2026-01-31' + n * INTERVAL '1 month')::date AS due_date
FROM generate_series(0, 3) AS n;
n | due_date
---+------------
0 | 2026-01-31
1 | 2026-02-28
2 | 2026-03-31
3 | 2026-04-30
1か月ずつ順に足していく書き方では、一度 28 日に丸められたあと 28 日のまま進みます。上の表の plus_1m_twice 列がその結果です。
また、INTERVAL '1 month - 1 day' のような複合 interval は「月を先に動かし、次に日を動かす」順で適用されます。足す順番で結果が変わる例です。
SELECT (DATE '2026-01-31' + INTERVAL '1 month - 1 day')::date AS a,
(DATE '2026-01-31' - INTERVAL '1 day' + INTERVAL '1 month')::date AS b;
a | b
------------+------------
2026-02-27 | 2026-02-28
a は「1月31日 → 2月28日(丸め)→ 2月27日」、b は「1月30日 → 2月28日(丸め)」です。月末付近の日付に月と日を同時に足すときは、date_trunc でいったん月初に揃えると、こうした差を気にせずに済みます。
日付の差分:引き算・age・extract の違い
日付の差を求める方法は3つあり、戻り値の型と意味が違います。
| 方法 | 戻り値の型 | 2026-01-31 → 2026-10-03 の結果 | 向いている用途 |
|---|---|---|---|
date - date |
integer | 245 | 経過日数、残り日数 |
timestamp - timestamp |
interval(日と時分秒) | 245 days 05:25:36 | 経過時間、処理時間 |
age(後の日時, 前の日時) |
interval(年・月・日) | 8 mons 3 days 05:25:36 | 年齢、勤続年数、「何年何か月」の表示 |
SELECT DATE '2026-10-03' - DATE '2026-01-31' AS date_minus_date,
pg_typeof(DATE '2026-10-03' - DATE '2026-01-31') AS t1;
SELECT TIMESTAMP '2026-10-03 14:25:36' - TIMESTAMP '2026-01-31 09:00:00' AS ts_minus_ts;
SELECT age(TIMESTAMP '2026-10-03 14:25:36', TIMESTAMP '2026-01-31 09:00:00') AS age_result,
age(DATE '2026-10-03', DATE '2024-02-29') AS age2;
date_minus_date | t1
-----------------+---------
245 | integer
ts_minus_ts
-------------------
245 days 05:25:36
age_result | age2
------------------------+-----------------------
8 mons 3 days 05:25:36 | 2 years 7 mons 3 days
timestamp の引き算は「245日と5時間25分36秒」と、月を使わず日数で返します。age は「8か月と3日」と、暦の年・月で返します。同じ期間でも表現が変わる点が、取り違えやすいところです。
日数・時間・秒で差を取り出す
SELECT extract(day FROM TIMESTAMP '2026-10-03 14:25:36' - TIMESTAMP '2026-01-31 09:00:00') AS days,
extract(epoch FROM TIMESTAMP '2026-10-03 14:25:36' - TIMESTAMP '2026-01-31 09:00:00') AS seconds,
round(extract(epoch FROM TIMESTAMP '2026-10-03 14:25:36'
- TIMESTAMP '2026-01-31 09:00:00') / 3600, 2) AS hours;
days | seconds | hours
------+-----------------+---------
245 | 21187536.000000 | 5885.43
時間数は epoch を 3600 で、分数は 60 で割ると求まります。
age の結果から日数を取り出すと小さな値になる
age の結果に extract(day ...) を使うと、「月を除いた残りの日」だけが返ります。
SELECT extract(day FROM age(DATE '2026-10-03', DATE '2026-01-31')) AS day_part_of_age,
DATE '2026-10-03' - DATE '2026-01-31' AS total_days;
day_part_of_age | total_days
-----------------+------------
3 | 245
通算の日数が欲しいときは date どうしの引き算、年・月・日に分けた表示が欲しいときは age、と使い分けます。
月数の差を出す2つの方法
「何か月たったか」には2つの数え方があります。
-- (1) 満の月数:age の年と月から求める
SELECT extract(year FROM a) * 12 + extract(month FROM a) AS full_months, a
FROM (SELECT age(DATE '2026-10-03', DATE '2024-02-29') AS a) AS s;
-- (2) 暦の月の差:年と月だけで引き算する
SELECT (extract(year FROM DATE '2026-10-03') - extract(year FROM DATE '2024-02-29')) * 12
+ (extract(month FROM DATE '2026-10-03') - extract(month FROM DATE '2024-02-29')) AS calendar_months;
full_months | a
-------------+-----------------------
31 | 2 years 7 mons 3 days
calendar_months
-----------------
32
満の月数(31)は利用期間などの経過月数に、暦の月の差(32)は月次レポートの「何か月前の月か」に向いています。
なお、age の「月」は月によって日数が違うため、端数の日の数え方に決まりがあります。公式ドキュメントには「2つの日付のうち早いほうの月を使って端数を計算する」とあります。手元でも次のようになりました。
SELECT age(DATE '2026-03-31', DATE '2026-02-28') AS a1,
age(DATE '2026-03-01', DATE '2026-01-31') AS a2;
a1 | a2
--------------+-------------
1 mon 3 days | 1 mon 1 day
日数を正確に扱いたい集計では、age よりも date どうしの引き算を使うと結果が読みやすくなります。
timestamptz とタイムゾーン指定(第3引数)
timestamptz に date_trunc('day', ...) をかけるとき、「どのタイムゾーンの 0:00 に切り捨てるか」 で結果が変わります。指定しないとセッションの TimeZone 設定が使われ、第3引数で明示もできます。
SET TIME ZONE 'Asia/Tokyo';
SELECT date_trunc('day', TIMESTAMPTZ '2026-10-03 03:00:00+09') AS session_tokyo,
date_trunc('day', TIMESTAMPTZ '2026-10-03 03:00:00+09', 'UTC') AS trunc_in_utc,
date_trunc('day', TIMESTAMPTZ '2026-10-03 03:00:00+09', 'America/New_York') AS trunc_in_ny;
session_tokyo | trunc_in_utc | trunc_in_ny
------------------------+------------------------+------------------------
2026-10-03 00:00:00+09 | 2026-10-02 09:00:00+09 | 2026-10-02 13:00:00+09
日本時間の 10月3日 3:00 は、UTC では 10月2日 18:00 です。そのため UTC で切り捨てると「UTC の 10月2日 0:00」=日本時間の 10月2日 9:00 になります。表示はセッションのタイムゾーン(+09)で行われます。
セッションが UTC のサーバーで、日本時間の日付で切りたい場合はこうなります。
SET TIME ZONE 'UTC';
SELECT date_trunc('day', TIMESTAMPTZ '2026-10-03 03:00:00+09') AS session_utc,
date_trunc('day', TIMESTAMPTZ '2026-10-03 03:00:00+09', 'Asia/Tokyo') AS arg_tokyo,
date_trunc('day', TIMESTAMPTZ '2026-10-03 03:00:00+09' AT TIME ZONE 'Asia/Tokyo') AS at_tz_then_trunc;
session_utc | arg_tokyo | at_tz_then_trunc
------------------------+------------------------+---------------------
2026-10-02 00:00:00+00 | 2026-10-02 15:00:00+00 | 2026-10-03 00:00:00
| 書き方 | 戻り値の型 | 結果の意味 |
|---|---|---|
date_trunc('day', tstz) |
timestamptz | セッションのタイムゾーンの 0:00。設定が変わると結果も変わる |
date_trunc('day', tstz, 'Asia/Tokyo') |
timestamptz | 日本時間の 0:00 を指す絶対時刻。セッション設定に左右されない |
date_trunc('day', tstz AT TIME ZONE 'Asia/Tokyo') |
timestamp | 日本時間の壁時計の日時(タイムゾーン情報なし)。表示や GROUP BY のキーに向く |
サーバーやコネクションの TimeZone 設定に結果を左右されたくないときは、第3引数か AT TIME ZONE でタイムゾーンを明示します。 日別・月別の集計で「日本時間の日付」ごとにまとめたい場合に効いてきます。
date_bin で15分・6時間など任意の幅に丸める
date_trunc は決まった単位でしか切れませんが、date_bin は「15分ごと」「6時間ごと」「7日ごと」のような任意の幅で切り捨てられます。引数は「幅、対象の日時、起点」の3つです。
SELECT ts,
date_bin('15 minutes', ts, TIMESTAMP '2001-01-01') AS bin_15min,
date_bin('6 hours', ts, TIMESTAMP '2001-01-01') AS bin_6h,
date_bin('7 days', ts, TIMESTAMP '2026-10-01') AS bin_7d_from_oct1
FROM (VALUES (TIMESTAMP '2026-10-03 14:25:36'),
(TIMESTAMP '2026-10-09 23:59:00')) AS t(ts);
ts | bin_15min | bin_6h | bin_7d_from_oct1
---------------------+---------------------+---------------------+---------------------
2026-10-03 14:25:36 | 2026-10-03 14:15:00 | 2026-10-03 12:00:00 | 2026-10-01 00:00:00
2026-10-09 23:59:00 | 2026-10-09 23:45:00 | 2026-10-09 18:00:00 | 2026-10-08 00:00:00
3つ目の列は「10月1日を起点にした7日ごとの区切り」です。起点を変えれば、任意の曜日から始まる週単位の集計にも使えます。
幅に月や年は指定できません。月単位・年単位は date_trunc の担当です。
SELECT date_bin('1 month', TIMESTAMP '2026-10-03 14:25:36', TIMESTAMP '2001-01-01');
ERROR: timestamps cannot be binned into intervals containing months or years
月別集計とインデックスが効く範囲条件
ここからは、テーブルに対して使う場面です。20万行の注文テーブルを作って確かめました(日時と金額は乱数で作っているため、件数は実行ごとに変わります)。
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
ordered_at timestamp NOT NULL,
amount integer NOT NULL
);
INSERT INTO orders (ordered_at, amount)
SELECT TIMESTAMP '2025-01-01' + (random() * 640) * INTERVAL '1 day',
(random() * 9000)::int + 1000
FROM generate_series(1, 200000);
CREATE INDEX orders_ordered_at_idx ON orders (ordered_at);
ANALYZE orders;
月別に件数と合計を出す
SELECT date_trunc('month', ordered_at)::date AS month,
count(*) AS orders,
sum(amount) AS total
FROM orders
WHERE ordered_at >= DATE '2026-07-01'
AND ordered_at < DATE '2026-10-01'
GROUP BY 1
ORDER BY 1;
month | orders | total
------------+--------+----------
2026-07-01 | 9545 | 52745523
2026-08-01 | 9698 | 53679525
2026-09-01 | 9319 | 51458480
SELECT と GROUP BY では date_trunc を使い、WHERE では列をそのまま範囲で絞る、という役割分担がポイントです。
データがない月も 0 件で出す
generate_series で月の一覧を作り、LEFT JOIN でつなぎます。
SELECT m::date AS month, count(o.id) AS orders
FROM generate_series(DATE '2026-08-01', DATE '2026-11-01', INTERVAL '1 month') AS m
LEFT JOIN orders o
ON o.ordered_at >= m
AND o.ordered_at < m + INTERVAL '1 month'
GROUP BY m
ORDER BY m;
month | orders
------------+--------
2026-08-01 | 9698
2026-09-01 | 9319
2026-10-01 | 698
2026-11-01 | 0
WHERE で列に date_trunc をかけると全件を読む
「9月分」を取り出す2通りの書き方で、実行計画を比べました。
-- A:列に関数をかける
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT count(*) FROM orders
WHERE date_trunc('month', ordered_at) = TIMESTAMP '2026-09-01';
Finalize Aggregate (actual rows=1 loops=1)
-> Gather (actual rows=2 loops=1)
Workers Planned: 1
Workers Launched: 1
-> Partial Aggregate (actual rows=1 loops=2)
-> Parallel Seq Scan on orders (actual rows=4660 loops=2)
Filter: (date_trunc('month'::text, ordered_at) = '2026-09-01 00:00:00'::timestamp without time zone)
Rows Removed by Filter: 95340
-- B:列はそのまま、範囲で絞る
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT count(*) FROM orders
WHERE ordered_at >= TIMESTAMP '2026-09-01'
AND ordered_at < TIMESTAMP '2026-09-01' + INTERVAL '1 month';
Aggregate (actual rows=1 loops=1)
-> Bitmap Heap Scan on orders (actual rows=9319 loops=1)
Recheck Cond: ((ordered_at >= '2026-09-01 00:00:00'::timestamp without time zone) AND (ordered_at < '2026-10-01 00:00:00'::timestamp without time zone))
Heap Blocks: exact=1274
-> Bitmap Index Scan on orders_ordered_at_idx (actual rows=9319 loops=1)
Index Cond: ((ordered_at >= '2026-09-01 00:00:00'::timestamp without time zone) AND (ordered_at < '2026-10-01 00:00:00'::timestamp without time zone))
| 書き方 | スキャン | 読んだ行 |
|---|---|---|
A:date_trunc('month', ordered_at) = ... |
Parallel Seq Scan(全件) | 20万行を読み、約19万行を捨てる |
B:ordered_at >= 月初 AND ordered_at < 翌月初 |
Bitmap Index Scan | 該当の 9,319 行だけ |
列を関数で包むと、その列の通常のインデックスは使われません。 月の条件は「月初以上、翌月初より前」の半開区間で書くと、インデックスが効きます。右辺の TIMESTAMP '2026-09-01' + INTERVAL '1 month' は定数として先に計算され、Index Cond に 2026-10-01 00:00:00 と展開されているのがわかります。
終わりの条件は「翌月初より前」にする
月末の 23:59:59 までを BETWEEN で指定すると、秒未満の値を持つ行が外れます。9月30日 23:59:59.5 の行を1件足して比べました。
INSERT INTO orders (ordered_at, amount) VALUES ('2026-09-30 23:59:59.5', 1000);
SELECT count(*) AS half_open FROM orders
WHERE ordered_at >= TIMESTAMP '2026-09-01' AND ordered_at < TIMESTAMP '2026-10-01';
SELECT count(*) AS between_235959 FROM orders
WHERE ordered_at BETWEEN TIMESTAMP '2026-09-01' AND TIMESTAMP '2026-09-30 23:59:59';
half_open
-----------
9320
between_235959
----------------
9319
>= 月初 AND < 翌月初 の形なら、マイクロ秒まで含めて漏れなく拾えます。 月の日数も気にせずに済みます。
どうしても date_trunc で絞りたいときは式インデックス
既存の SQL を変えにくい場合は、式インデックスを作る方法があります。
CREATE INDEX orders_month_idx ON orders (date_trunc('month', ordered_at));
ANALYZE orders;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT count(*) FROM orders
WHERE date_trunc('month', ordered_at) = TIMESTAMP '2026-09-01';
Aggregate (actual rows=1 loops=1)
-> Bitmap Heap Scan on orders (actual rows=9320 loops=1)
-> Bitmap Index Scan on orders_month_idx (actual rows=9320 loops=1)
Index Cond: (date_trunc('month'::text, ordered_at) = '2026-09-01 00:00:00'::timestamp without time zone)
(Recheck Cond と Heap Blocks の行は省略しています。)
列が timestamptz の場合は、タイムゾーンを明示した式でないと式インデックスを作れません。結果がセッション設定で変わる式は、インデックスに使えない決まりだからです。
CREATE TABLE ev (at timestamptz);
CREATE INDEX ON ev (date_trunc('month', at)); -- エラーになる
CREATE INDEX ON ev (date_trunc('month', at, 'Asia/Tokyo')); -- 作成できる
CREATE INDEX ON ev (date_trunc('month', at AT TIME ZONE 'Asia/Tokyo')); -- 作成できる
ERROR: functions in index expression must be marked IMMUTABLE
CREATE INDEX
CREATE INDEX
式インデックスは単位ごとに数が増え、書き込み時の負荷も増えます。迷ったら、まず範囲条件への書き換えを選ぶと、インデックスの数を増やさずに済みます。
つまずきやすい点のチェックリスト
| 確認すること | 見るポイント |
|---|---|
結果に 00:00:00+09 が付く |
date に date_trunc をかけている。::date を付ける |
| 週の始まりが想定と1日ずれる | week は月曜始まり。日曜始まりは date_trunc('week', d + 1) - INTERVAL '1 day' |
| 月末が 28 日のまま進む | 1か月ずつ順に足している。起点に n * INTERVAL '1 month' を足す |
| 日数の差が小さすぎる | age の結果から day を取り出している。date - date を使う |
| 日別集計の日付が9時間ずれる | timestamptz を UTC のまま切っている。第3引数か AT TIME ZONE で指定する |
| 月の検索が遅い | WHERE で列に date_trunc をかけている。半開区間の範囲条件にする |
| 月末のデータが数件足りない | BETWEEN で 23:59:59 までにしている。< 翌月初 にする |
| date_bin で月を指定してエラー | 月・年は date_trunc を使う |
よくある質問
Q. date_trunc の結果を date 型で受け取るにはどうすればよいですか?
A. 末尾に ::date を付けます。date 型を渡しても戻り値は timestamp with time zone になるため、date_trunc('month', d)::date の形にすると日付だけになります。
Q. 月末を求める関数はありますか?
A. PostgreSQL 16.15 では (date_trunc('month', d) + INTERVAL '1 month' - INTERVAL '1 day')::date で求める形が広く使われており、うるう年の2月も正しく 29 日になることを確認しています。
Q. 「1か月後」と「30日後」は同じですか?
A. 違います。INTERVAL '1 month' は暦の月を1つ進め、INTERVAL '30 days' や + 30 は日数をそのまま足します。10月3日の1か月後は11月3日、30日後は11月2日です。請求や更新日のように「毎月同じ日」に意味がある場合は month、経過日数に意味がある場合は日数を使います。
Q. date_trunc と extract はどう違いますか? A. date_trunc は切り捨てた日時を返し、extract は年・月・日などの数値を取り出します。月ごとにまとめるキーには date_trunc(年をまたいでも区別できる)、「何月か」だけが欲しいときは extract が向いています。
まとめ
- date_trunc は日時を指定単位で切り捨てる関数で、microseconds から millennium まで13単位。week は月曜始まり
- date を渡しても戻り値は timestamptz なので、日付が欲しいときは
::dateを付ける - 月末は「月初 + 1 month - 1 day」。うるう年も同じ式で正しく求まる
- 1月31日 + 1 month は2月末に丸められる。「+2か月」と「+1か月を2回」は結果が違うので、起点に合計の月数を足す
- 差分は用途で使い分ける:日数は date の引き算(integer)、経過時間は timestamp の引き算(interval)、年月日の表示は age
- timestamptz は第3引数か AT TIME ZONE でタイムゾーンを明示すると、セッション設定に左右されない
- WHERE では列を関数で包まず「月初以上・翌月初より前」 の範囲条件にすると、インデックスが効き、月末の取りこぼしもなくなる
参考・出典
- PostgreSQL 公式ドキュメント「9.9. Date/Time Functions and Operators」(date_trunc の単位一覧と第3引数、date_bin の制約、age の端数の扱い、月の加算での丸めを確認)https://www.postgresql.org/docs/current/functions-datetime.html
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「PostgreSQL date_truncとintervalで月初・月末・差分」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。