
COUNT в SQL: почему число строк отличается от числа заполненных значений


Одна из самых частых ошибок в отчетах по SQL выглядит безобидно: в таблице шесть заявок, а в колонке с ответственным получается то четыре, то шесть, то три. Запросы формально правильные, но отвечают на разные вопросы. Поэтому расхождение обычно связано не с «неисправным COUNT», а с тем, что мы считаем разные объекты.
В PostgreSQL это особенно заметно на полях, где встречаются и NULL, и пустая строка ''. Они не равны друг другу и должны разбираться отдельно. Ниже — один узкий сценарий: шесть заявок, у двух не указан ответственный, у одной вместо имени записана пустая строка.
Какие объекты на самом деле считает COUNT
Когда аналитик пишет COUNT, он часто мысленно хочет получить «сколько заявок с ответственным». Но у базы нет такого намерения по умолчанию. Она видит только строки и значения в выражении. По документации PostgreSQL, count(*) считает входные строки, а count(expression) считает только те строки, где выражение не равно NULL.
Из-за этого у одного и того же столбца появляются как минимум три разных подсчета. Первый — сколько вообще строк в таблице заявок. Второй — в скольких строках поле owner не равно NULL. Третий — в скольких строках в owner есть осмысленное имя, а не техническая пустота вроде ''. Если не разделить эти вопросы заранее, отчет легко завышает заполненность данных.
Пустая строка здесь особенно коварна. Для человека она выглядит как отсутствие значения, а для SQL это обычное строковое значение нулевой длины. Поэтому COUNT(owner) включает такую строку в подсчет, хотя для операционного отчета «ответственный назначен» это обычно неверная интерпретация.
Три подсчета на одной таблице заявок
Учебный пример. Возьмем шесть условных заявок. У заявок 2 и 5 ответственный не указан совсем, то есть там NULL. У заявки 3 в поле попала пустая строка. Остальные три строки содержат имена.
| id | topic | owner |
|---|---|---|
| 1 | Возврат | Анна |
| 2 | Оплата | NULL |
| 3 | Доставка | |
| 4 | Договор | Борис |
| 5 | Смена тарифа | NULL |
| 6 | Акт | Ирина |
WITH requests(id, topic, owner) AS (
VALUES
(1, 'Возврат', 'Анна'),
(2, 'Оплата', NULL),
(3, 'Доставка', ''),
(4, 'Договор', 'Борис'),
(5, 'Смена тарифа', NULL),
(6, 'Акт', 'Ирина')
)
SELECT
COUNT(*) AS rows_total,
COUNT(owner) AS owner_not_null,
COUNT(NULLIF(owner, '')) AS owner_meaningful
FROM requests;Результат будет таким: rows_total = 6, owner_not_null = 4, owner_meaningful = 3. Первое число считает все заявки. Второе исключает только NULL, поэтому пустая строка по заявке 3 все еще считается значением. Третье число сначала превращает именно пустую строку в NULL через NULLIF, а затем считает только непустые значения. Так мы получаем три реально заполненных имени: Анна, Борис и Ирина.
Это и есть причина, почему «число строк» отличается от «числа заполненных значений». На одном и том же наборе данных запросы не спорят между собой: они отвечают на три разных вопроса. Ошибка возникает позже, когда число 4 читают как «четыре заявки с назначенным ответственным», хотя одна из них на самом деле содержит пустое имя.
Как проверить пропуски и пустые строки отдельно
Полезнее всего не пытаться сразу получить одну красивую метрику, а сначала разложить поле owner на состояния. Это снимает путаницу между отсутствием данных и некачественной строкой. Для оперативной работы это разные проблемы: NULL может означать, что ответственного еще не назначили, а '' — что имя потерялось при загрузке или очистке данных.
WITH requests(id, topic, owner) AS (
VALUES
(1, 'Возврат', 'Анна'),
(2, 'Оплата', NULL),
(3, 'Доставка', ''),
(4, 'Договор', 'Борис'),
(5, 'Смена тарифа', NULL),
(6, 'Акт', 'Ирина')
)
SELECT
SUM(CASE WHEN owner IS NULL THEN 1 ELSE 0 END) AS owner_null,
SUM(CASE WHEN owner = '' THEN 1 ELSE 0 END) AS owner_empty_string,
SUM(CASE WHEN owner IS NOT NULL AND owner <> '' THEN 1 ELSE 0 END) AS owner_filled
FROM requests;На нашем наборе выйдет: owner_null = 2, owner_empty_string = 1, owner_filled = 3. Эти числа лучше объясняют ситуацию, чем один общий показатель. Видно, что две заявки действительно без значения, а еще одна заявка содержит формально строку, но по смыслу поле не заполнено.
Если в данных бывают строки из одних пробелов, для отчета можно ввести отдельное правило нормализации, например NULLIF(BTRIM(owner), ''). Важно не перепутать правило отчета со смыслом хранения. Мы не объявляем, что NULL и пустая строка одинаковы. Мы лишь договариваемся, что для метрики «есть осмысленное имя ответственного» пустые и пробельные строки надо исключить из заполненных значений.
Как выбрать правильный знаменатель для отчета
После подсчета начинается вторая типичная ошибка: взять верный числитель и неверный знаменатель. Если вопрос звучит как «у какой доли заявок назначен ответственный», знаменателем должны быть все заявки. В нашем примере это 3 / 6 = 0.5, то есть 50%. Именно столько заявок имеют осмысленно заполненное поле owner.
Если вместо этого использовать COUNT(owner) / COUNT(*), получится 4 / 6 = 0.6667, или 66,7%. Метрика выглядит лучше, но она завышена за счет пустой строки. Такой расчет отвечает лишь на вопрос «в скольких строках поле не равно NULL», а не «в скольких строках назначен ответственный».
Иногда нужен и другой показатель, но уже для качества загрузки текста. Например, среди строк, где в колонку вообще что-то записали, какая доля содержит непустое имя. Тогда знаменатель — COUNT(owner) = 4, а числитель — COUNT(NULLIF(owner, '')) = 3. Получаем 3 / 4 = 75%. Это полезная метрика для контроля импорта, но она не заменяет долю заявок с назначенным ответственным.
WITH requests(id, topic, owner) AS (
VALUES
(1, 'Возврат', 'Анна'),
(2, 'Оплата', NULL),
(3, 'Доставка', ''),
(4, 'Договор', 'Борис'),
(5, 'Смена тарифа', NULL),
(6, 'Акт', 'Ирина')
)
SELECT
ROUND(100.0 * COUNT(NULLIF(owner, '')) / NULLIF(COUNT(*), 0), 1) AS pct_requests_with_owner,
ROUND(100.0 * COUNT(NULLIF(owner, '')) / NULLIF(COUNT(owner), 0), 1) AS pct_meaningful_among_not_null
FROM requests;На этих данных запрос вернет 50.0 и 75.0. Оба числа верны, если правильно подписаны. В отчетах проблема обычно не в арифметике, а в том, что метрика названа слишком общо и читатель принимает 75% за заполненность по всем заявкам.
Как не ошибаться в рабочих запросах
Перед любым COUNT полезно проговорить вопрос целиком: я считаю строки, ненулевые значения или осмысленно заполненные значения после нормализации? Затем стоит отдельно проверить проблемные состояния через CASE и посмотреть на конкретные идентификаторы строк. На маленькой выборке это быстрее, чем искать причину в уже собранном дашборде.
WITH requests(id, topic, owner) AS (
VALUES
(1, 'Возврат', 'Анна'),
(2, 'Оплата', NULL),
(3, 'Доставка', ''),
(4, 'Договор', 'Борис'),
(5, 'Смена тарифа', NULL),
(6, 'Акт', 'Ирина')
)
SELECT
id,
owner,
CASE
WHEN owner IS NULL THEN 'null'
WHEN owner = '' THEN 'empty_string'
ELSE 'filled'
END AS owner_state
FROM requests
ORDER BY id;Такой контрольный вывод особенно полезен перед публикацией отчета. Он позволяет проверить граничные случаи: не потерялись ли строки с NULL, не попали ли пустые строки в «заполненные», не перепутан ли знаменатель. В нашем учебном кейсе проверяемый итог простой: всего 6 заявок, реально назначенный ответственный у 3, доля по всем заявкам — 50%.
Если хотите потренировать именно такие задачи, во внутренней карточке каталога курса SQL для анализа данных сохранена программа с темами по SELECT, строковым функциям, агрегатным функциям и группировке, работе с NULL, объединениям и CASE. На официальной странице школы эти темы тоже перечислены, поэтому для самостоятельной практики можно ориентироваться не только на написание запроса, но и на проверку того, что именно он считает.





















