「テーブルの行数を数えたいだけなのに、なんでこんなに遅いの?」 「COUNT(*)とCOUNT(1)の違いって何?」 「大規模テーブルの行数を高速に取得する方法は?」
PostgreSQLで行数を取得する方法は、実は状況によって最適な手法が異なります。
単純にCOUNT(*)を使えばいいと思っていませんか?実は、テーブルのサイズやインデックス、精度の要求によって、もっと効率的な方法があるんです。
この記事では、基本的なCOUNT関数から、数億行のテーブルでも高速に動作する上級テクニックまで、すべてお伝えします!
基本編:COUNT関数の使い方

📊 COUNT(*):最も基本的な行数取得
全行数を取得
-- テーブルの全行数を取得
SELECT COUNT(*) FROM users;
-- 結果例
count
-------
1234
(1 row)
特徴:
- NULL値も含めてすべての行をカウント
- 最も一般的で確実な方法
- インデックスがあっても全件スキャンすることがある
🎯 条件付きカウント:WHERE句と組み合わせ
-- アクティブユーザーの数を取得
SELECT COUNT(*) FROM users WHERE status = 'active';
-- 複数条件
SELECT COUNT(*)
FROM orders
WHERE created_at >= '2025-01-01'
AND status = 'completed';
-- 結果を分かりやすく表示
SELECT COUNT(*) AS active_user_count
FROM users
WHERE status = 'active';
🔍 COUNT関数のバリエーション
COUNT(*) vs COUNT(1) vs COUNT(列名)
-- すべて同じ結果?いいえ、違います!
-- COUNT(*):すべての行をカウント
SELECT COUNT(*) FROM products; -- 結果:1000
-- COUNT(1):すべての行をカウント(*と同じ)
SELECT COUNT(1) FROM products; -- 結果:1000
-- COUNT(column):NULLを除外してカウント
SELECT COUNT(description) FROM products; -- 結果:950(NULL除外)
-- COUNT(DISTINCT):重複を除外
SELECT COUNT(DISTINCT category_id) FROM products; -- 結果:15
使い分けの指針:
方法 | 用途 | NULL値 | パフォーマンス |
---|---|---|---|
COUNT(*) | 全行数 | 含む | 標準 |
COUNT(1) | 全行数 | 含む | *と同じ |
COUNT(列名) | 特定列の非NULL数 | 除外 | 列による |
COUNT(DISTINCT 列) | ユニーク値の数 | 除外 | 遅い |
グループ別の行数取得
📈 GROUP BYで集計する
基本的なグループ集計
-- カテゴリー別の商品数
SELECT
category_id,
COUNT(*) AS product_count
FROM products
GROUP BY category_id
ORDER BY product_count DESC;
-- 結果例
category_id | product_count
-------------+---------------
1 | 234
5 | 189
3 | 156
複数列でのグループ化
-- 年月別の注文数
SELECT
DATE_TRUNC('month', created_at) AS month,
status,
COUNT(*) AS order_count
FROM orders
GROUP BY DATE_TRUNC('month', created_at), status
ORDER BY month DESC, status;
-- 結果例
month | status | order_count
--------------------+------------+-------------
2025-01-01 00:00:00 | completed | 1234
2025-01-01 00:00:00 | pending | 456
2024-12-01 00:00:00 | completed | 1189
🎨 HAVING句で集計結果をフィルタ
-- 100件以上の注文があるユーザーのみ表示
SELECT
user_id,
COUNT(*) AS order_count
FROM orders
GROUP BY user_id
HAVING COUNT(*) >= 100
ORDER BY order_count DESC;
-- 複数条件のHAVING
SELECT
category_id,
COUNT(*) AS product_count,
AVG(price) AS avg_price
FROM products
GROUP BY category_id
HAVING COUNT(*) > 10
AND AVG(price) > 1000;
パフォーマンスを考慮した行数取得
⚡ 大規模テーブルでの高速化テクニック
方法1:システムカタログを使用(概算値)
-- pg_stat_user_tablesから概算値を取得(超高速)
SELECT
schemaname,
tablename,
n_live_tup AS approximate_row_count
FROM pg_stat_user_tables
WHERE tablename = 'large_table';
-- より正確な概算値(pg_class)
SELECT
reltuples::BIGINT AS approximate_row_count
FROM pg_class
WHERE relname = 'large_table';
-- 結果例(実際:10,234,567行)
approximate_row_count
----------------------
10234000
メリット:
- 瞬時に結果が返る(数ミリ秒)
- CPU/IO負荷がほぼゼロ
デメリット:
- あくまで概算値(誤差あり)
- VACUUM/ANALYZE実行後の値
方法2:インデックスオンリースキャン
-- インデックスがある列でカウント
CREATE INDEX idx_users_status ON users(status);
-- インデックスを使った高速カウント
EXPLAIN (ANALYZE, BUFFERS)
SELECT COUNT(*) FROM users WHERE status = 'active';
-- カバリングインデックスの活用
CREATE INDEX idx_orders_covering
ON orders(user_id)
INCLUDE (created_at, status);
方法3:パーティションテーブルの並列カウント
-- パーティションごとに並列でカウント
SELECT
SUM(cnt) AS total_count
FROM (
SELECT COUNT(*) AS cnt FROM orders_2025_01
UNION ALL
SELECT COUNT(*) AS cnt FROM orders_2025_02
UNION ALL
SELECT COUNT(*) AS cnt FROM orders_2024_12
) AS partition_counts;
高度なカウントテクニック
🚀 ウィンドウ関数を使った累積カウント
-- 累積カウント(ランニングトータル)
SELECT
created_at::DATE AS date,
COUNT(*) AS daily_count,
SUM(COUNT(*)) OVER (ORDER BY created_at::DATE) AS cumulative_count
FROM orders
GROUP BY created_at::DATE
ORDER BY date;
-- 結果例
date | daily_count | cumulative_count
------------+-------------+------------------
2025-01-01 | 100 | 100
2025-01-02 | 150 | 250
2025-01-03 | 200 | 450
📊 条件別カウント(CASE文使用)
-- 複数の条件を一度にカウント
SELECT
COUNT(*) AS total_orders,
COUNT(CASE WHEN status = 'completed' THEN 1 END) AS completed_orders,
COUNT(CASE WHEN status = 'pending' THEN 1 END) AS pending_orders,
COUNT(CASE WHEN status = 'cancelled' THEN 1 END) AS cancelled_orders,
COUNT(CASE WHEN amount > 10000 THEN 1 END) AS high_value_orders
FROM orders
WHERE created_at >= CURRENT_DATE - INTERVAL '30 days';
-- FILTER句を使った方法(PostgreSQL 9.4以降)
SELECT
COUNT(*) AS total_orders,
COUNT(*) FILTER (WHERE status = 'completed') AS completed_orders,
COUNT(*) FILTER (WHERE status = 'pending') AS pending_orders,
COUNT(*) FILTER (WHERE amount > 10000) AS high_value_orders
FROM orders
WHERE created_at >= CURRENT_DATE - INTERVAL '30 days';
🔄 CTE(共通テーブル式)を使った複雑な集計
-- 複数段階の集計
WITH user_orders AS (
SELECT
user_id,
COUNT(*) AS order_count,
SUM(amount) AS total_amount
FROM orders
WHERE created_at >= '2025-01-01'
GROUP BY user_id
),
user_categories AS (
SELECT
CASE
WHEN order_count >= 10 THEN 'heavy_user'
WHEN order_count >= 5 THEN 'regular_user'
ELSE 'light_user'
END AS user_type,
COUNT(*) AS user_count,
AVG(order_count) AS avg_orders
FROM user_orders
GROUP BY user_type
)
SELECT * FROM user_categories
ORDER BY
CASE user_type
WHEN 'heavy_user' THEN 1
WHEN 'regular_user' THEN 2
WHEN 'light_user' THEN 3
END;
リアルタイム行数カウントの実装

📈 カウンターテーブルを使った高速化
カウンターテーブルの設計
-- カウンターテーブルの作成
CREATE TABLE row_counters (
table_name TEXT PRIMARY KEY,
row_count BIGINT NOT NULL DEFAULT 0,
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- トリガー関数の作成
CREATE OR REPLACE FUNCTION update_row_counter()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE row_counters
SET row_count = row_count + 1,
last_updated = CURRENT_TIMESTAMP
WHERE table_name = TG_TABLE_NAME;
ELSIF TG_OP = 'DELETE' THEN
UPDATE row_counters
SET row_count = row_count - 1,
last_updated = CURRENT_TIMESTAMP
WHERE table_name = TG_TABLE_NAME;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
-- トリガーの設定
CREATE TRIGGER update_users_counter
AFTER INSERT OR DELETE ON users
FOR EACH ROW EXECUTE FUNCTION update_row_counter();
-- 初期値の設定
INSERT INTO row_counters (table_name, row_count)
SELECT 'users', COUNT(*) FROM users;
-- 使用例(超高速!)
SELECT row_count FROM row_counters WHERE table_name = 'users';
🔄 マテリアライズドビューで集計を事前計算
-- 集計結果を事前計算
CREATE MATERIALIZED VIEW order_summary AS
SELECT
DATE_TRUNC('day', created_at) AS date,
status,
COUNT(*) AS order_count,
SUM(amount) AS total_amount
FROM orders
GROUP BY DATE_TRUNC('day', created_at), status;
-- インデックスの作成
CREATE INDEX idx_order_summary_date ON order_summary(date);
-- 定期的な更新(cronで実行)
REFRESH MATERIALIZED VIEW CONCURRENTLY order_summary;
-- 高速クエリ
SELECT SUM(order_count) AS total_orders
FROM order_summary
WHERE date >= '2025-01-01';
実行計画の確認と最適化
🔍 EXPLAINで実行計画を確認
-- 基本的なEXPLAIN
EXPLAIN SELECT COUNT(*) FROM large_table;
-- 詳細な分析
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT COUNT(*) FROM large_table WHERE status = 'active';
-- 実行計画の読み方
QUERY PLAN
--------------------------------------------------------------------------
Aggregate (cost=12345.67..12345.68 rows=1 width=8)
(actual time=234.567..234.568 rows=1 loops=1)
Buffers: shared hit=1234 read=5678
-> Seq Scan on large_table (cost=0.00..11234.56 rows=444444 width=0)
(actual time=0.123..198.765 rows=445678 loops=1)
Filter: (status = 'active'::text)
Rows Removed by Filter: 554322
Buffers: shared hit=1234 read=5678
重要な指標:
- cost:推定コスト(低いほど良い)
- rows:推定行数
- actual time:実際の実行時間
- Seq Scan vs Index Scan:スキャン方式
⚙️ 最適化のヒント
-- 統計情報の更新
ANALYZE large_table;
-- 自動バキュームの設定確認
SELECT
schemaname,
tablename,
last_vacuum,
last_autovacuum,
last_analyze,
last_autoanalyze,
vacuum_count,
autovacuum_count
FROM pg_stat_user_tables
WHERE tablename = 'large_table';
-- work_memの調整(セッション単位)
SET work_mem = '256MB';
-- 並列処理の有効化
SET max_parallel_workers_per_gather = 4;
よくあるパターンと解決策
💡 ケース1:日次レポート用の行数取得
-- 日別のアクティビティサマリー
WITH daily_stats AS (
SELECT
created_at::DATE AS date,
COUNT(*) AS total_actions,
COUNT(DISTINCT user_id) AS unique_users,
COUNT(*) FILTER (WHERE action_type = 'login') AS login_count,
COUNT(*) FILTER (WHERE action_type = 'purchase') AS purchase_count
FROM user_activities
WHERE created_at >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY created_at::DATE
)
SELECT
date,
total_actions,
unique_users,
login_count,
purchase_count,
ROUND(100.0 * purchase_count / NULLIF(unique_users, 0), 2) AS conversion_rate
FROM daily_stats
ORDER BY date DESC;
📊 ケース2:ページネーション用の総件数取得
-- 効率的なページネーション
-- 方法1:概算値で十分な場合
WITH count_estimate AS (
SELECT reltuples::BIGINT AS total_count
FROM pg_class
WHERE relname = 'products'
)
SELECT
p.*,
ce.total_count
FROM products p
CROSS JOIN count_estimate ce
WHERE p.category_id = 5
ORDER BY p.created_at DESC
LIMIT 20 OFFSET 0;
-- 方法2:正確な件数が必要な場合(キャッシュ推奨)
SELECT
json_build_object(
'total_count', (SELECT COUNT(*) FROM products WHERE category_id = 5),
'page_data', json_agg(p.*)
) AS result
FROM (
SELECT * FROM products
WHERE category_id = 5
ORDER BY created_at DESC
LIMIT 20 OFFSET 0
) p;
🔥 ケース3:ホットデータの行数監視
-- リアルタイムモニタリング用ビュー
CREATE VIEW table_row_counts AS
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS total_size,
n_live_tup AS live_rows,
n_dead_tup AS dead_rows,
ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_ratio,
last_vacuum,
last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_live_tup DESC;
-- 使用例
SELECT * FROM table_row_counts WHERE dead_ratio > 10;
パフォーマンス比較表
📊 手法別の実行時間比較
手法 | 1万行 | 100万行 | 1億行 | 精度 | 用途 |
---|---|---|---|---|---|
COUNT(*) | 1ms | 100ms | 10秒 | 100% | 正確な値が必要 |
pg_stat_user_tables | <1ms | <1ms | <1ms | 95-99% | 概算で十分 |
カウンターテーブル | <1ms | <1ms | <1ms | 100% | リアルタイム |
マテリアライズドビュー | <1ms | <1ms | 1ms | 99.9% | 定期更新 |
サンプリング(TABLESAMPLE) | 1ms | 10ms | 100ms | 90-95% | 統計用 |
よくある質問と回答
Q:COUNT(*)とCOUNT(1)はどちらが速い?
A: PostgreSQLではパフォーマンスに差はありません。オプティマイザが同じ実行計画を生成します。可読性の観点からCOUNT(*)を推奨します。
Q:数億行のテーブルで正確な行数が必要な場合は?
A: カウンターテーブルかマテリアライズドビューを使用してください。リアルタイム性が必要ならトリガーベースのカウンター、多少の遅延が許容できるなら定期更新のマテリアライズドビューが最適です。
Q:DELETEした行もカウントされる?
A: COUNT(*)では削除済み行はカウントされません。ただし、VACUUMが実行されるまで物理的には存在するため、pg_stat_user_tablesのn_dead_tupには含まれます。
Q:パーティションテーブルの全行数を効率的に取得するには?
A: 各パーティションを並列でカウントして合計するか、pg_classから各パーティションのreltuplesを合計する方法が高速です。
Q:行数が0かどうかだけを確認したい
A: EXISTS
を使用してください。SELECT EXISTS(SELECT 1 FROM table_name LIMIT 1)
が最速です。COUNT(*)は全件スキャンするので非効率です。
まとめ:状況に応じた最適な行数取得を!
PostgreSQLでの行数取得は、要求される精度とパフォーマンスのバランスを考慮して手法を選択することが重要です。
手法選択のガイドライン:
- 正確な値が必要 → COUNT(*)を使用
- 概算で十分 → pg_stat_user_tablesを活用
- リアルタイム監視 → カウンターテーブル実装
- 定期レポート → マテリアライズドビュー
- 大規模テーブル → サンプリングやパーティション分割
パフォーマンス改善のポイント:
- 適切なインデックスの作成
- 統計情報の定期更新(ANALYZE)
- 並列処理の活用
- キャッシュ戦略の検討
用途に応じて最適な手法を選択し、効率的なデータベース運用を実現しましょう!
高速で正確な行数取得を! 🚀📊
コメント