COUNT 関数は SQL で最も頻繁に使う集計関数のひとつです。「テーブルの全レコード数」から「特定条件を満たす件数」「重複を除いた種類数」「グループごとの件数」まで、引数の書き方によって動作が大きく変わります。
特に COUNT(*) と COUNT(列名) の NULL の扱いの違い は実務でよく混乱するポイントです。この記事では COUNT 関数の全形式の違い・NULL 挙動・GROUP BY / HAVING との組み合わせ・条件付きカウント・ウィンドウ関数形式・主要 RDBMS の差異まで体系的に解説します。
COUNT の 3 種類の書き方と違い
COUNT には引数の書き方が 3 種類あり、それぞれ集計対象が異なります。
-- ① COUNT(*) : NULL を含むすべての行数をカウント SELECT COUNT(*) FROM orders; -- 結果: 7 -- ② COUNT(列名) : 指定列が NULL でない行数をカウント SELECT COUNT(amount) FROM orders; -- 結果: 6(amountがNULLの行6を除く) -- ③ COUNT(DISTINCT 列名) : 重複を除いた種類数をカウント SELECT COUNT(DISTINCT customer_id) FROM orders; -- 結果: 5(101〜105)
| 書き方 | カウント対象 | NULL の扱い | 主な用途 |
|---|---|---|---|
COUNT(*) |
全行 | 含む | テーブル全体の行数・件数確認 |
COUNT(列名) |
指定列が NULL でない行 | 除外 | 値が存在する行の件数 |
COUNT(DISTINCT 列名) |
指定列の重複を除いた行 | 除外 | ユニークな値の種類数 |
-- サンプルテーブル: orders(注文テーブル) -- order_id | customer_id | status | amount | region -- ---------+-------------+-----------+--------+--------- -- 1 | 101 | shipped | 5000 | east -- 2 | 102 | pending | 3000 | west -- 3 | 101 | shipped | 8000 | east -- 4 | 103 | cancelled | 2000 | east -- 5 | 102 | shipped | 6000 | west -- 6 | 104 | pending | NULL | north -- 7 | 105 | NULL | 4000 | east
NULL の挙動:COUNT(*) と COUNT(列名) の違い
COUNT 関数で最も重要な知識が NULL の扱いです。間違えると件数がずれて集計ミスにつながります。
-- status が NULL の行(行7)が 1 件ある
SELECT
COUNT(*) AS count_all, -- 7 ← NULL行も含む
COUNT(status) AS count_status, -- 6 ← statusがNULLの行7を除く
COUNT(amount) AS count_amount -- 6 ← amountがNULLの行6を除く
FROM orders;
-- 結果:
-- count_all | count_status | count_amount
-- ----------+--------------+-------------
-- 7 | 6 | 6
COUNT(列名) は指定した列の値が NULL の行を カウントしません。テーブルの実際の件数を数えたいのに COUNT(nullable_column) を使うと、NULL が含まれる行が抜け落ちて正確な件数が取れなくなります。テーブル全体の行数には必ず COUNT(*) を使ってください。-- NULL の行だけを数える(IS NULL) SELECT COUNT(*) AS null_count FROM orders WHERE status IS NULL; -- 結果: 1 -- NULL でない行だけを数える(IS NOT NULL) SELECT COUNT(*) AS non_null_count FROM orders WHERE status IS NOT NULL; -- 結果: 6 -- または COUNT(列名) で直接取得(IS NOT NULL と等価) SELECT COUNT(status) AS non_null_count FROM orders; -- 結果: 6
WHERE と組み合わせて条件付きでカウントする
WHERE 句で行を絞り込んでからカウントするのが基本的な条件付きカウントです。
-- status が 'shipped' の注文数
SELECT COUNT(*) AS shipped_count
FROM orders
WHERE status = 'shipped'; -- 結果: 3
-- 特定地域かつ金額が3000以上の件数
SELECT COUNT(*) AS filtered_count
FROM orders
WHERE region = 'east'
AND amount >= 3000; -- 結果: 2(行1と行3)
-- 複数条件を OR で
SELECT COUNT(*) AS shipped_or_pending
FROM orders
WHERE status IN ('shipped', 'pending'); -- 結果: 5
条件付きカウント:CASE WHEN で複数条件を 1 クエリで集計する
WHERE を使うと 1 つの条件でしか絞り込めません。CASE WHEN を COUNT 内で使うと、1 クエリで複数の条件ごとの件数を同時に取得できます。レポート系 SQL で頻繁に使うテクニックです。
-- 非効率: テーブルを3回スキャンしている SELECT COUNT(*) FROM orders WHERE status = 'shipped'; SELECT COUNT(*) FROM orders WHERE status = 'pending'; SELECT COUNT(*) FROM orders WHERE status = 'cancelled';
-- 効率的: テーブルを1回スキャンするだけ
SELECT
COUNT(*) AS total,
COUNT(CASE WHEN status = 'shipped' THEN 1 END) AS shipped_count,
COUNT(CASE WHEN status = 'pending' THEN 1 END) AS pending_count,
COUNT(CASE WHEN status = 'cancelled' THEN 1 END) AS cancelled_count,
COUNT(CASE WHEN amount >= 5000 THEN 1 END) AS high_value_count
FROM orders;
-- 結果:
-- total | shipped_count | pending_count | cancelled_count | high_value_count
-- ------+---------------+---------------+-----------------+-----------------
-- 7 | 3 | 2 | 1 | 2
CASE WHEN 条件が真のとき
1、偽のとき NULL(END で暗黙的に NULL)を返します。COUNT は NULL を除外するため、条件が真の行だけがカウントされます。SUM(CASE WHEN ... THEN 1 ELSE 0 END) でも同じ結果が得られますが、COUNT(CASE WHEN ... THEN 1 END) の方が意図が明確です。GROUP BY でグループごとにカウントする
GROUP BY と組み合わせることで、グループ(カテゴリ)ごとの件数を一度に集計できます。
-- ステータスごとの件数 SELECT status, COUNT(*) AS cnt FROM orders GROUP BY status ORDER BY cnt DESC; -- 結果: -- status | cnt -- ----------+---- -- shipped | 3 -- pending | 2 -- cancelled | 1 -- NULL | 1 ← NULLのステータスも1グループとして集計される
-- 2件以上注文した顧客のみ表示 SELECT customer_id, COUNT(*) AS order_count FROM orders GROUP BY customer_id HAVING COUNT(*) >= 2 ORDER BY order_count DESC; -- 結果: -- customer_id | order_count -- ------------+------------ -- 101 | 2 -- 102 | 2 -- WHERE は集計前の絞り込み、HAVING は集計後の絞り込み -- 組み合わせ例: shipped 注文が2件以上の顧客 SELECT customer_id, COUNT(*) AS shipped_count FROM orders WHERE status = 'shipped' -- 先にshippedのみに絞る GROUP BY customer_id HAVING COUNT(*) >= 2;
- WHERE: GROUP BY の前に個別行を絞り込む(集計関数は使えない)
- HAVING: GROUP BY の後に集計結果を絞り込む(COUNT() などを使える)
- GROUP BY + COUNT 詳細についてはGROUP BYでグループカウントする方法も参照してください。
COUNT(DISTINCT) で重複を除いた種類数を数える
同じ顧客が複数回注文していても「顧客数」は 1 として数えたい、というケースに COUNT(DISTINCT 列名) を使います。
-- ユニークな顧客数(重複する customer_id を除く) SELECT COUNT(DISTINCT customer_id) AS unique_customers FROM orders; -- 結果: 5(101, 102, 103, 104, 105) -- ユニークなステータスの種類数(NULLは除外) SELECT COUNT(DISTINCT status) AS status_types FROM orders; -- 結果: 3(shipped, pending, cancelled。NULLは除外) -- 地域ごとのユニーク顧客数 SELECT region, COUNT(DISTINCT customer_id) AS unique_customers FROM orders GROUP BY region ORDER BY region; -- 結果: -- region | unique_customers -- -------+----------------- -- east | 3 -- north | 1 -- west | 2
ウィンドウ関数 COUNT() OVER でグループ件数を行ごとに持つ
通常の GROUP BY では集計するとその他の列が見えなくなりますが、ウィンドウ関数を使うと元の行をそのまま残しながら、グループごとの件数を各行に付与できます。
-- 各注文行に「同じ顧客の総注文数」を付与する
SELECT
order_id,
customer_id,
status,
amount,
COUNT(*) OVER (PARTITION BY customer_id) AS customer_order_count
FROM orders
ORDER BY customer_id, order_id;
-- 結果:
-- order_id | customer_id | status | amount | customer_order_count
-- ---------+-------------+-----------+--------+---------------------
-- 1 | 101 | shipped | 5000 | 2
-- 3 | 101 | shipped | 8000 | 2
-- 2 | 102 | pending | 3000 | 2
-- 5 | 102 | shipped | 6000 | 2
-- 4 | 103 | cancelled | 2000 | 1
-- 6 | 104 | pending | NULL | 1
-- 7 | 105 | NULL | 4000 | 1
-- テーブル全体の件数を各行に付与(ページネーションのトータル件数などに便利)
SELECT
order_id,
customer_id,
COUNT(*) OVER () AS total_count -- PARTITION BY なし = 全体
FROM orders;
-- 結果: total_count が全行で 7 になる
-- 各注文が「同一顧客の注文全体」に占める金額の割合
SELECT
order_id,
customer_id,
amount,
COUNT(*) OVER (PARTITION BY customer_id) AS orders_in_group,
ROUND(amount * 100.0
/ SUM(amount) OVER (PARTITION BY customer_id), 1) AS amount_pct
FROM orders
WHERE amount IS NOT NULL;
COUNT(*) vs COUNT(1) のパフォーマンス誤解
「COUNT(1) の方が COUNT(*) より速い」という説がありますが、現代の主要 RDBMS ではほぼ同じです。
| 書き方 | 意味 | 速度 | 推奨度 |
|---|---|---|---|
COUNT(*) |
全行をカウント(NULL含む) | 最適化済み | ◎ 推奨 |
COUNT(1) |
定数 1 をカウント(= COUNT(*) と同じ) | COUNT(*) と同等 | ○ 動作は同じ |
COUNT(pk列) |
主キー列をカウント(NULL除外) | インデックス次第 | △ 主キーなら COUNT(*) と同じだが混乱しやすい |
SQL 標準で定義されており、MySQL・PostgreSQL・Oracle・SQL Server のいずれのオプティマイザも
COUNT(*) を認識して最適なインデックス(最小幅の非クラスタインデックスなど)を自動選択します。COUNT(1) も内部的に COUNT(*) と同様に処理されますが、読み手への意図の明確さという点で COUNT(*) が標準的です。主要 RDBMS ごとの COUNT の注意点
| RDBMS | 注意点 |
|---|---|
| MySQL / MariaDB | COUNT(DISTINCT col1, col2) の複数列指定が可能(他RDBMSは不可) |
| PostgreSQL | COUNT(DISTINCT col) は大テーブルで低速になりやすい。pg_class.reltuples で概算値取得も可能 |
| Oracle | COUNT(DISTINCT) と GROUP BY の組み合わせで Composite Aggregate Optimization が効く場合がある |
| SQL Server | COUNT(*) は int を返す(2億件超は COUNT_BIG(*) で bigint を返す) |
| SQLite | 標準的な COUNT(*) / COUNT(列) / COUNT(DISTINCT 列) はすべて対応 |
-- SQL Server: 20億を超えるレコード数は COUNT(*) だと int オーバーフロー -- → COUNT_BIG(*) で bigint を返す SELECT COUNT_BIG(*) AS total_rows FROM very_large_table;
-- PostgreSQL: 正確な件数より「だいたいの件数」でよい場合の高速代替 SELECT reltuples::bigint AS approx_count FROM pg_class WHERE relname = 'orders'; -- テーブル名を指定 -- VACUUM/ANALYZE 後の統計情報をもとにした概算値 -- フルスキャン不要で非常に高速
実務でよく使うパターン集
-- 各列の NULL 件数と NULL 率を一度に確認する
SELECT
COUNT(*) AS total_rows,
COUNT(*) - COUNT(customer_id) AS customer_id_nulls,
COUNT(*) - COUNT(status) AS status_nulls,
COUNT(*) - COUNT(amount) AS amount_nulls,
ROUND((COUNT(*) - COUNT(amount)) * 100.0
/ COUNT(*), 1) AS amount_null_pct
FROM orders;
-- 全顧客数と注文済み顧客数を比較して「注文したことがある顧客の割合」を求める
SELECT
(SELECT COUNT(DISTINCT customer_id) FROM orders) AS ordered_customers,
(SELECT COUNT(*) FROM customers) AS total_customers,
ROUND(
(SELECT COUNT(DISTINCT customer_id) FROM orders) * 100.0
/ (SELECT COUNT(*) FROM customers),
1
) AS conversion_pct;
-- MySQL / PostgreSQL(DATE関数の書き方が異なる) -- MySQL: SELECT DATE(created_at) AS order_date, COUNT(*) AS daily_count FROM orders WHERE created_at >= CURDATE() - INTERVAL 30 DAY GROUP BY DATE(created_at) ORDER BY order_date; -- PostgreSQL: SELECT created_at::date AS order_date, COUNT(*) AS daily_count FROM orders WHERE created_at >= CURRENT_DATE - INTERVAL '30 days' GROUP BY created_at::date ORDER BY order_date;
よくある質問(FAQ)
COUNT(*) を使ってください。COUNT(1) は書いても動作は同じですが、SQL 標準は COUNT(*) を全行カウントの標準的な書き方として定めており、すべての主要 RDBMS でオプティマイザが同等に最適化します。「COUNT(1) の方が速い」という説は現代の RDBMS では根拠がありません。コードの可読性と標準準拠の観点からも COUNT(*) を推奨します。SELECT s.status, COUNT(o.order_id) FROM (VALUES ('shipped'),('pending'),('cancelled')) AS s(status) LEFT JOIN orders o ON o.status = s.status GROUP BY s.status のように、すべての値を持つ側から LEFT JOIN することで 0 件グループも表示できます。pg_class.reltuples や MySQL の information_schema.TABLES.TABLE_ROWS で統計値を参照する。④集計結果をキャッシュ・マテリアライズドビューに事前集計しておく(バッチ処理との組み合わせ)。COALESCE(status, '未設定') で GROUP BY すると「未設定」という文字列でまとめられます。COUNT(status) は NULL を除外しますが、COUNT(*) で GROUP BY すれば NULL グループの件数も取得できます。まとめ
| やりたいこと | 書き方 |
|---|---|
| 全行数をカウント | COUNT(*) |
| NULL を除いた件数 | COUNT(列名) |
| 重複を除いた種類数 | COUNT(DISTINCT 列名) |
| 条件に合う件数(単条件) | COUNT(*) WHERE 条件 |
| 複数条件を 1 クエリで集計 | COUNT(CASE WHEN 条件 THEN 1 END) |
| グループごとの件数 | COUNT(*) ... GROUP BY 列名 |
| グループ件数で絞り込む | GROUP BY ... HAVING COUNT(*) >= n |
| 行を残しながらグループ件数を付与 | COUNT(*) OVER (PARTITION BY 列名) |
| SQL Server で bigint を返す | COUNT_BIG(*) |
GROUP BY を使ったグループごとのカウントと HAVING での絞り込みの詳細はGROUP BYでグループカウントする方法を、重複データの集計にはGROUP BY + SUM を使った重複データの集計方法を、平均値の算出にはAVG関数で平均値を求める方法も参照してください。
