❄️

COUNT(*), COUNT(1), COUNT(expr) の違いを SQL 標準から理解する

2025/02/19に公開

Intro

COUNT(*), COUNT(1), COUNT(expr) の違いについて、おそらく NULL の扱いだったり、パフォーマンスだったりが違うんだろうな、という経験的に得られた知識があると思いますが、これを ANSI SQL 標準 (ISO/IEC 9075-2:2016) を元に体系的に整理します。

ANSI SQL における COUNT 集約関数の定義

ISO/IEC 9075-2:2016 の "4.16.4 Aggregate functions" にて、COUNT (および単一引数の集約関数) は下記のように定義されています。

  • COUNT(*) は集約内の行数を返す
  • それ以外の単一引数の集約関数は、任意の <value expression> を引数に取る
  • 各行に対する <value expression> について、下記を満たす行を除外する:
    • DISTINCT キーワードがある場合、重複値になる行
    • <value expression> が NULL に評価される行
  • 上記のルール適用後に、何も行が残らなかった場合、COUNT は 0 を、それ以外の単一引数の集約関数は NULL を返す
  • COUNT <value expression> は、上記のルール適用後の集約内の行数を返す

この定義をベースに、各記述の動作を考えます。

各記述の動作

COUNT(*)

COUNT(*) は前述の通り、特別なケースとして独立して定義されており、常に集約内の行数を返します。

したがって、その行が全列 NULL であろうがなんだろうが、すべて含めた行数が返ります。

COUNT(1)

COUNT(1) は、パフォーマンスの観点で COUNT(*) の代わりに使うといい、とよく言われていたりする式ですが、これは <value expression>1 の場合を考えればよいことになります。

1 は非 NULL の定数なので、これが NULL として評価されることはなく、また DISTINCT キーワードも存在しないため、すべての行が条件を満たすこととなり、集約内のすべての行数が返ります。

したがって、これも COUNT(*) と同じ結果となります。

また、あらゆる非 NULL の定数は NULL に評価されることがないため、COUNT('🍣') でも COUNT(CURRENT_TIMESTAMP()) でも COUNT(false) でも何でも同じ動作 (集約中の行数を返す) になります。

一方で COUNT(NULL) については、全行で NULL に評価されるため、常に 0 を返します。

COUNT(expr)

COUNT(expr)、すなわち COUNT の引数に任意の式 (列名含む) を渡したときの動作ですが、これは「expr が NULL に評価される行が除外される」という動作になります。

したがって、例えば COUNT(col1) のように列名が渡された場合、col1 が NULL を格納している行が除外された行数が返ります。

またこれを応用すると COUNT_IFFILTER(WHERE ...) を使わなくても、条件付きカウントができることがわかります(これがいいかはともかくとして)。

create or replace table t1 (c1 int) as
select seq4() from table(generator(rowcount => 100));

select count(iff(c1 < 50, c1, null)) from t1;
-- 50

上記のクエリでは、[0,100] の連番が格納されている C1 について、[0,50] の範囲で C1 を、[51,100] の範囲で NULL を返すような式を引数に渡しているので、100 行中 50 行が除外され、結果は 50 になります。

Appendix: 各記述のパフォーマンスの違い

COUNT は統計情報 (メタデータ) が活かしやすい集約関数なので、各製品がどのようなメタデータを保持しているかによって大きくパフォーマンスが異なります。

そのため、ここからは Snowflake での例に基づいて、各記述のパフォーマンスの差異について整理します。

メタデータだけで結果を返すことができるケース

実際にデータをスキャンすることなく、メタデータだけで結果を返すことができるケースは、メタデータがどのような情報を持っているかを考えるとわかりやすいです。

まず、最もシンプルな GROUP BY なしの (テーブル全体に対する) COUNT(*) ですが、これはメタデータのみで結果を返すことができます。

これは、テーブル全体の行数を、各マイクロパーティションの行数の合計から計算できるからです。

create or replace table t1 (c1 int) as
select seq4()
from table(generator(rowcount => 100000000));

-- To unset the current warehouse
create or replace warehouse temp;
drop warehouse temp;

select count(*) from t1;
-- 100000000
-- 20 msec

また、COUNT(1) を含む COUNT(<const>) についても、GROUP BY 句が存在しない場合、COUNT(NULL) は 0、それ以外はテーブル行数になるため、COUNT(*) と同様にメタデータのみで結果を返すことができます。

select count(1)
from t1;
-- 1000000000
-- 21 msec

select count('foo')
from t1;
-- 1000000000
-- 21 msec

select count(null)
from t1;
-- 0
-- 20 msec

COUNT(expr) ですが、expr が列参照の場合、Snowflake が列ごとの NULL 値の数をメタデータに持っているので、メタデータのみで結果を返すことができます。

create or replace table t1 (c1 int) as
select iff(seq4()%2=0, seq4(), null)
from table(generator(rowcount => 100000000));

-- To unset the current warehouse
create or replace warehouse temp;
drop warehouse temp;

select count(c1) from t1;
-- 50000000
-- 60 msec

カラム化された JSON 要素についての COUNT (例: COUNT(c1:id::int)) もメタデータを使用できますが、そもそもカラム化されるのは NULL 値を含まない場合のみなので、実用上の意味はありません (常に全行数が返されるので COUNT(*) と同じ)。

https://docs.snowflake.com/ja/user-guide/semistructured-considerations#elements-that-are-not-extracted

抽出されない要素
現在、次の特性を持つ要素は列に抽出されません。
・ 単一の「null」値を含む要素は列に抽出されません。これは、値が欠落している要素ではなく、列指向形式で表される「null」値をともなう要素に適用されます。

expr が式の場合は、式に対するメタデータは存在しないため、実際にスキャンが必要になります。

select count(c1/2) from t1;
-- No active warehouse selected in the current session. Select an active warehouse with the 'use warehouse' command.

use warehouse xsmall;

select count(c1/2) from t1;
-- 50000000
-- 367 msec

また、テーブル全体が対象でない場合、マイクロパーティション粒度の情報であるメタデータだけでは計算できないため、テーブルスキャンが必要になります。

例えば GROUP BY 句が存在する場合、ヒストグラムのような各値ごとの行数のメタデータは存在しないため、実際にスキャンして計算することになります。

select count(*)
from t1
group by c1;
-- No active warehouse selected in the current session. Select an active warehouse with the 'use warehouse' command.

use warehouse xsmall;

select count(*)
from t1
group by c1;
-- 4.6 sec

WHERE 句も同様で、各マイクロパーティションのメタデータは最大値・最小値を持ってはいますが、そこから任意の値を持つ行数を厳密に特定することはできないため、実際にスキャンして計算することになります。

ただし、WHERE 句条件によってパーティションプルーニングできる場合、テーブルスキャンや集約対象の行数自体を減らすことができるため、パフォーマンスの改善は見られます。

select count(*)
from t1
where c1 < 1000000;
-- No active warehouse selected in the current session. Select an active warehouse with the 'use warehouse' command.

use warehouse xsmall;

select count(*)
from t1
where c1 < 1000000;
-- 310 msec

COUNT(*) v.s. COUNT(1)

COUNT(1) のほうが速いという説があったり、COUNT(*) のほうが速いという説があったりし、また製品によっては実際にどっちかが速い可能性がありますが、Snowflake ではほぼ同じです。

select count(1)
from t1
group by c1;
-- 4.7 sec

select count(*)
from t1
group by c1;
-- 4.6 sec

実行機序としてもほぼ同じで、COUNT(1) のほうが引数の 1 のプロジェクションが追加で必要にはなるだけの違いになりますが、定数のプロジェクションなどほぼ無視できるオーバーヘッドなので、本当に変わりません。

上記のクエリも、おそらく何回も実行してると COUNT(1) のほうが速いケースが出てくると思います。

Conclusion

SQL の動作については、経験から理解することも大事ですが、どこかのタイミングで ANSI SQL 標準をもとに理解を整理してみると、忘れにくくなるのでおすすめです。

ただし、あなたの使っている製品がどれだけ ANSI SQL 準拠かはわからないので、その通りにならない場合もあることをご注意ください。

Discussion