📘

PostgreSQL の FILTER 句を活用した効率的な集計

2025/03/03に公開

PostgreSQL でデータを集計する際、COUNT(*)SUM() などの集約関数を使うことが多いですが、特定の条件でフィルタした件数を取得する場合、FILTER 句を使うとクエリがシンプルで可読性が高くなります。FILTER 句は PostgreSQL 9.4 から導入された拡張機能で、標準 SQL にはない PostgreSQL 特有の機能ですが、その有用性から他の DBMS でも徐々に採用されつつあります。

FILTER 句とは?

通常、集約関数で条件付きの集計を行う場合、CASE WHEN ... THEN ... ELSE ... END を使うこともあると思いますが、FILTER 句を使うとより直感的に記述できます。

FILTER 句の構文は以下のような形式です。

集約関数(...) FILTER (WHERE 条件式)

FILTER句は以下のような一般的な集計関数と組み合わせて使用できます。

  • COUNT, SUM, AVG - カウントや数値の集計
  • MIN, MAX - 最小値や最大値の取得
  • ARRAY_AGG - 値の配列への集約
  • STRING_AGG - 文字列の連結
  • JSONB_AGG - JSON配列への集約
  • 統計関数 (STDDEV, VARIANCE, CORR など)

FILTER 句 / CASE WHEN を使って、1分単位で無効・有効なデータを集計する

例えば、以下のような device_logs テーブルがあるとします。
このテーブルには、IoT デバイスから送信された位置情報のログが格納されています。

CREATE TABLE device_logs (
    id UUID PRIMARY KEY,
    device_id VARCHAR(255) NOT NULL, -- デバイスの ID
    event_time TIMESTAMPTZ NOT NULL, -- データの発生時間
    location GEOGRAPHY(Point,4326) NULL -- IoT デバイスの位置情報を格納するカラム
);

たとえば、以下の条件で 1 分単位のデータ件数を集計したいとします。

  • locationNULL または (0,0) のデータを「無効」とする
  • それ以外の location を「有効」とする
  • device_id ごとに集計する

CASE WHEN を使った場合

通常、CASE WHEN を使うと以下のようになります。

SELECT
  date_trunc('minute', event_time) AS minute_time,
  device_id,
  SUM(
    CASE
      WHEN location IS NULL
      OR location = ST_GeomFromEWKT('SRID=4326;POINT(0 0)') THEN 1
      ELSE 0
    END
  ) AS invalid_count,
  SUM(
    CASE
      WHEN location IS NOT NULL
      AND location <> ST_GeomFromEWKT('SRID=4326;POINT(0 0)') THEN 1
      ELSE 0
    END
  ) AS valid_count
FROM
  device_logs
GROUP BY
  minute_time,
  device_id
ORDER BY
  minute_time,
  device_id;

結果は以下のようになります。

      minute_time       | device_id | invalid_count | valid_count
------------------------+-----------+---------------+-------------
 2025-03-01 12:33:00+00 | device_1  |             0 |          11
 2025-03-01 12:33:00+00 | device_2  |            11 |           0
 2025-03-01 12:37:00+00 | device_1  |             0 |          18
 2025-03-01 12:37:00+00 | device_2  |            18 |           0
 2025-03-01 12:38:00+00 | device_1  |             0 |           4
 2025-03-01 12:38:00+00 | device_2  |             4 |           0
 2025-03-01 12:53:00+00 | device_1  |             0 |          12
 2025-03-01 12:53:00+00 | device_2  |            12 |           0
 2025-03-01 13:09:00+00 | device_1  |             0 |          23
 2025-03-01 13:09:00+00 | device_2  |            23 |           0
 2025-03-01 13:10:00+00 | device_1  |             0 |           1
 2025-03-01 13:10:00+00 | device_2  |             1 |           0
 2025-03-01 13:17:00+00 | device_1  |             0 |          45
 2025-03-01 13:17:00+00 | device_2  |            45 |           0
 2025-03-01 13:18:00+00 | device_1  |             0 |          32
 2025-03-01 13:18:00+00 | device_2  |            32 |           0
(16 rows)

この書き方でも問題ありませんが、SUM(CASE WHEN ... THEN 1 ELSE 0 END) の部分が冗長になりがちです。同じ処理を FILTER 句を使うと、より分かりやすく記述できます。

FILTER 句を使った場合

SELECT
  date_trunc('minute', event_time) AS minute_time,
  device_id,
  COUNT(*) FILTER (
    WHERE
      location IS NULL
      OR location = ST_GeomFromEWKT('SRID=4326;POINT(0 0)')
  ) AS invalid_count,
  COUNT(*) FILTER (
    WHERE
      location IS NOT NULL
      AND location <> ST_GeomFromEWKT('SRID=4326;POINT(0 0)')
  ) AS valid_count
FROM
  device_logs
GROUP BY
  minute_time,
  device_id
ORDER BY
  minute_time,
  device_id;

結果は以下のようになります。同じ結果が得られています。

      minute_time       | device_id | invalid_count | valid_count
------------------------+-----------+---------------+-------------
 2025-03-01 12:33:00+00 | device_1  |             0 |          11
 2025-03-01 12:33:00+00 | device_2  |            11 |           0
 2025-03-01 12:37:00+00 | device_1  |             0 |          18
 2025-03-01 12:37:00+00 | device_2  |            18 |           0
 2025-03-01 12:38:00+00 | device_1  |             0 |           4
 2025-03-01 12:38:00+00 | device_2  |             4 |           0
 2025-03-01 12:53:00+00 | device_1  |             0 |          12
 2025-03-01 12:53:00+00 | device_2  |            12 |           0
 2025-03-01 13:09:00+00 | device_1  |             0 |          23
 2025-03-01 13:09:00+00 | device_2  |            23 |           0
 2025-03-01 13:10:00+00 | device_1  |             0 |           1
 2025-03-01 13:10:00+00 | device_2  |             1 |           0
 2025-03-01 13:17:00+00 | device_1  |             0 |          45
 2025-03-01 13:17:00+00 | device_2  |            45 |           0
 2025-03-01 13:18:00+00 | device_1  |             0 |          32
 2025-03-01 13:18:00+00 | device_2  |            32 |           0
(16 rows)

パフォーマンスは異なるのか?

FILTER 句を使うと、集約関数の前にフィルタリングが適用されるため、無駄な計算が減ると考えられます。ただし、FILTER 句は集約関数ごとに適用されるため、集約関数の数が多いとオーバーヘッドになることがあるようです。

一方、CASE WHEN では 1回のスキャンで複数の条件を評価するため、場合によってはより効率的になるようです。

また、Postgreのバージョンによっては、クエリプランナーの最適化が異なるため、FILTER 句の方が遅いと言及している記事もありました。

実際に検証してみた(Postgres 17)

実際に1,000,000行のデータを投入後に、両方のクエリの実行計画を測定してみました。
EXPLAIN ANALYZEを実行した結果、どちらも同じ実行計画になりました。クエリオプティマイザが内部的に同様の処理に変換しているためと考えられます。

実行計画

 Finalize GroupAggregate  (cost=25853.69..25861.13 rows=28 width=33) (actual time=466.924..469.205 rows=17 loops=1)
   Group Key: (date_trunc('minute'::text, event_time)), device_id
   ->  Gather Merge  (cost=25853.69..25860.22 rows=56 width=33) (actual time=466.919..469.194 rows=19 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         ->  Sort  (cost=24853.67..24853.74 rows=28 width=33) (actual time=456.617..456.618 rows=6 loops=3)
               Sort Key: (date_trunc('minute'::text, event_time)), device_id
               Sort Method: quicksort  Memory: 25kB
               Worker 0:  Sort Method: quicksort  Memory: 25kB
               Worker 1:  Sort Method: quicksort  Memory: 25kB
               ->  Partial HashAggregate  (cost=24852.64..24852.99 rows=28 width=33) (actual time=456.587..456.588 rows=6 loops=3)
                     Group Key: date_trunc('minute'::text, event_time), device_id
                     Batches: 1  Memory Usage: 24kB
                     Worker 0:  Batches: 1  Memory Usage: 24kB
                     Worker 1:  Batches: 1  Memory Usage: 24kB
                     ->  Parallel Seq Scan on device_logs  (cost=0.00..17558.85 rows=416788 width=49) (actual time=0.031..98.971 rows=333431 loops=3)
 Planning Time: 0.112 ms
 Execution Time: 469.242 ms

クエリの実行時間は以下の通りです。

  • FILTER 句を使ったクエリ: 平均実行時間 471ms
  • CASE WHEN を使ったクエリ: 平均実行時間 483ms

同じ実行計画を生成しているため、クエリの実行時間はほぼ同じでした。
パフォーマンスに大きな差がない場合、FILTER 句を使用する方が可読性が高く、冗長なコードを避けられるため、FILTER 句を使用する方が望ましいと考えられます。

まとめ

繰り返しになりますが、PostgreSQL の FILTER 句を使うことで、条件付きの集約クエリをよりシンプルに記述できます。

CASE WHEN よりも可読性が高く、冗長なコードを避けられる
SUM()AVG() などの他の集約関数とも組み合わせ可能
✅ クエリがシンプルになり、メンテナンスしやすい

パフォーマンスを比較しつつ、適切な場面で活用してみてください!

参考

https://www.postgresql.jp/docs/15/tutorial-agg.html

GitHubで編集を提案

Discussion