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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?