Привет, Хабр!
Отчёт по выручке за месяц сходится с бухгалтерией, а тот же отчёт в разрезе по товарам даёт на треть больше. Оба запроса написаны одним человеком, оба проходят ревью, оба возвращают числа без ошибок и предупреждений. Разница только в том, что во втором добавлен JOIN с позициями заказа. Он размножил строки, там, где заказ был один, их стало три, и сумма по нему выросла втрое.
Особенность SQL в том, что он почти никогда не отвечает «не понял вопроса». Он отвечает числом на тот вопрос, который вы задали, — даже если спросить вы хотели другое. Ошибка не падает, не подсвечивается и не ловится тестом на схему, она просто уезжает в дашборд и живёт там до первой сверки с чужими данными.
В статье рассморим семь таких мест. Первые три растут из NULL: сравнение с ним не даёт ни правды, ни лжи. Три следующие — из строк, которых стало больше или меньше, чем вы думали. Последняя — из границ, по которым режут время. Все прогоны сделаны в DuckDB 1.5.5 и продублированы в SQLite — два независимых движка дали одинаковые числа до последнего. Растёт это поведение из трёхзначной логики стандарта, так что в Postgres и MySQL результаты будут те же.
Три COUNT на один вопрос — три разных числа
Начнём с таблицы заказов, где половина полей заполнена, а половина нет. Это не выдуманный случай: promo_code пустой у всех, кто заказал без промокода, а amount пустой у заказа, который ещё не рассчитан.
CREATE TABLE orders (id INT, customer_id INT, amount DECIMAL(10,2), promo_code VARCHAR); INSERT INTO orders VALUES (1, 10, 1000.00, 'SALE'), (2, 10, 2000.00, NULL), (3, 11, 3000.00, NULL), (4, 12, 500.00, 'SALE'), (5, 12, NULL, NULL);
Теперь четыре счётчика в одном запросе:
SELECT COUNT(*) AS zvezda, COUNT(promo_code) AS po_kolonke, COUNT(DISTINCT customer_id) AS klientov, COUNT(amount) AS s_summoy FROM orders;
zvezda po_kolonke klientov s_summoy 5 2 3 4
Пять, два, три и четыре — и все четыре ответа правильные, просто на разные вопросы. COUNT(*) считает строки, COUNT(колонка) считает строки, где колонка не пуста, COUNT(DISTINCT ...) считает уникальные значения. Разница вылезает тогда, когда в колонке есть NULL, а он там есть почти всегда.
Такая проблема всплывает в двух местах:
1) когда «количество заказов» в отчёте считают через COUNT(какое-нибудь_поле), потому что так короче писать, и получают заниженное число.
2) когда сравнивают два отчёта, написанных разными людьми: один взял COUNT(*), другой COUNT(id), и пока id не пустой, числа совпадают. Как только в выборке возникает LEFT JOIN без совпадения, числа расходятся, а причину ищут в бизнес‑логике.
С AVG та же история, только заметить труднее, потому что среднее не с чем сравнить:
SELECT AVG(amount) AS avg_funkciya, SUM(amount)/COUNT(*) AS ruchnoy_schet, SUM(amount) AS summa, COUNT(*) AS strok FROM orders;
avg_funkciya ruchnoy_schet summa strok 1625.0 1300.0 6500.0 5
AVG поделил на четыре, потому что пятую строку с пустой суммой он выбросил, а ручной счёт поделил на пять. Какой из вариантов правильный, зависит от того, что значит нерассчитанный заказ: если это «ещё нет суммы» — прав AVG, если это «сумма ноль» — прав ручной счёт.
Строка с NULL выпадает и из «равно», и из «не равно»
Следующая ошибка ломает арифметику, к которой все привыкли с детства. Возьмём клиентов, у одного из которых регион не заполнен:
CREATE TABLE customers (id INT, name VARCHAR, region VARCHAR); INSERT INTO customers VALUES (10,'Ромашка','Север'), (11,'Василёк','Юг'), (12,'Пион',NULL), (13,'Астра','Север');
Считаем северных, не‑северных и всех:
region = 'Север' -> 2 region <> 'Север' -> 1 всего строк -> 4
Два плюс один даёт три, а строк четыре. Пион с пустым регионом не попал ни в одну половину. Сравнение с NULL не возвращает ни истину, ни ложь: оно возвращает NULL, а WHERE пропускает только строки, где условие истинно.
Беда здесь не в самом факте, про него все читали, а в том, как он всплывает. Отчёт «продажи по регионам» будет сходиться внутри себя: сложите все группы — получится меньше общей суммы, но кто же складывает группы глазами. Расхождение всплывёт через месяц, когда кто‑нибудь сверит итог с бухгалтерией. К тому времени никто уже не вспомнит, что в справочнике был десяток клиентов без региона.
Исправаляет это тем, что NULL перестаёт быть неявным. Либо перкидываем его в отдельную группу через COALESCE(region, 'не указан'), либо ставим NOT NULL на колонку и разбираемся с данными на входе.
NOT IN с одним NULL возвращает пустоту
То же самое правило, доведённое до абсурда, даёт третью ошибку. Есть список заблокированных клиентов, и в него однажды попала пустая строка:
CREATE TABLE blocked (customer_id INT); INSERT INTO blocked VALUES (11), (NULL);
Хотим посчитать всех незаблокированных. Три способа спросить одно и то же:
SELECT count(*) FROM customers WHERE id NOT IN (SELECT customer_id FROM blocked); SELECT count(*) FROM customers c WHERE NOT EXISTS ( SELECT 1 FROM blocked b WHERE b.customer_id = c.id); SELECT count(*) FROM customers WHERE id IN (SELECT customer_id FROM blocked);
NOT IN -> 0 NOT EXISTS -> 3 IN -> 1
NOT IN вернул ноль незаблокированных клиентов при четырёх клиентах и одном блоке. Логика тут формально безупречна. id NOT IN (11, NULL) разворачивается в id <> 11 AND id <> NULL, вторая часть даёт NULL, всё выражение перестаёт быть истинным, и строка не проходит. И так для каждой строки, поэтому результат пуст.
А вот IN от этого не страдает, ему достаточно одного истинного сравнения, и NULL в списке просто не совпадает ни с чем. То есть один и тот же NULL ломает отрицание и не трогает утверждение, и заметить это на маленьких данных почти невозможно.
Отсюда правило: NOT IN с подзапросом никогда не пишем, пишем NOT EXISTS. Он даёт правильный ответ независимо от пустот, а заодно обычно и план получше. Если NOT IN уже написан по всему проекту, минимальная страховка — дописать в подзапрос WHERE customer_id IS NOT NULL, но это лечение симптома.
JOIN размножил строки, и выручка выросла на треть
Дальше начинаются ошибки не про пустоту, а про количество строк. Добавим к заказам их позиции:
CREATE TABLE items (order_id INT, sku VARCHAR, qty INT); INSERT INTO items VALUES (1,'A',1),(1,'B',2),(1,'C',1),(2,'A',5),(3,'D',1),(4,'A',1),(4,'B',1);
И посчитаем выручку двумя запросами — до присоединения позиций и после:
SELECT SUM(amount) FROM orders; SELECT SUM(o.amount) FROM orders o JOIN items i ON i.order_id = o.id;
без join: 6500 с join: 9000
Две с половиной тысячи взялись из воздуха. У первого заказа три позиции, значит после присоединения он представлен тремя строками, и его тысяча просуммировалась трижды. У четвёртого позиций две — его пятьсот сложились дважды. Никакой ошибки в запросе нет, SUM честно сложил всё, что ему дали.
Аналитик добавляет JOIN, чтобы получить разрез по товарам, и в том же запросе оставляет старый SUM(amount). Разрез получается, итог уезжает, а сходится всё до копейки только на заказах с одной позицией.
Признак, по которому это ловится, простой: сумма из таблицы «один заказ — одна строка» считается после присоединения таблицы «один заказ — много строк». Чинится это тем, что позиции сначала сворачиваются в агрегат, а присоединяется уже он:
SELECT SUM(o.amount) FROM orders o JOIN (SELECT order_id, SUM(qty) AS q FROM items GROUP BY order_id) i ON i.order_id = o.id;
6500
Есть и второй путь, который выглядит короче и потому встречается чаще, прикрыть размножение через DISTINCT:
SELECT SUM(DISTINCT o.amount) FROM orders o JOIN items i ON i.order_id = o.id;
6500
Число сошлось, и на этом обычно успокаиваются. Но проблема в том, что DISTINCT схлопывает не лишние строки, а одинаковые значения, и разницы между ними он не понимает. Добавим ещё один заказ, тоже ровно на тысячу:
правда 7500 SUM(DISTINCT ...) 6500 через подзапрос 7500
Настоящий заказ на тысячу рублей исчез из выручки, потому что такая сумма в выборке уже была. С деньгами повторяющиеся значения — обычное дело, так что DISTINCT тут не лечение, а отложенная ошибка. Сработает она не сразу и уже без всякой связи с тем JOIN, из‑за которого её поставили.
LEFT JOIN, который превратился в INNER одной строкой
Пятая ошибка живёт в фильтрах. Возьмём пользователей и их платежи, причём у Веры платежей нет вовсе, а у Бори платёж неуспешный:
CREATE TABLE users (id INT, name VARCHAR); INSERT INTO users VALUES (1,'Аня'),(2,'Боря'),(3,'Вера'); CREATE TABLE payments (user_id INT, amount INT, status VARCHAR); INSERT INTO payments VALUES (1, 500, 'ok'), (2, 300, 'failed');
Задача обычная: показать всех пользователей и их успешные платежи. Пишем LEFT JOIN, потому что пользователей надо показать всех, и добавляем фильтр по статусу:
SELECT count(*) FROM users u LEFT JOIN payments p ON p.user_id = u.id; SELECT count(*) FROM users u LEFT JOIN payments p ON p.user_id = u.id WHERE p.status = 'ok'; SELECT count(*) FROM users u LEFT JOIN payments p ON p.user_id = u.id AND p.status = 'ok';
LEFT JOIN как есть: 3 LEFT JOIN + WHERE status: 1 условие перенесли в ON: 3
Строчка WHERE p.status = 'ok' превратила левое соединение в обычное внутреннее. Порядок такой: сначала LEFT JOIN доклеивает Вере строку с пустыми платежами, потом WHERE проверяет NULL = 'ok', получает NULL и выбрасывает Веру. Заодно выбрасывает и Борю, у которого платёж есть, но неуспешный.
Условие в ON участвует в соединении и не может выкинуть левую строку, а условие в WHERE работает уже по результату и выкидывает что угодно. Поэтому всё, что отнсится к правой таблице в левом соединении, живёт в ON, а в WHERE остаётся только то, что относится к левой.
У той же пары есть и вторая беда помельче:
SELECT u.name, COUNT(*) AS zvezda, COUNT(p.amount) AS po_kolonke FROM users u LEFT JOIN payments p ON p.user_id = u.id GROUP BY u.name;
name zvezda po_kolonke Аня 1 1 Боря 1 1 Вера 1 0
У Веры нет ни одного платежа, а COUNT(*) показывает единицу, потому что строка после левого соединения всё‑таки есть, просто набитая пустотами. Считать платежи надо по колонке, а не по звёздочке, и вот это тот случай из первого раздела, только теперь понятно, откуда там берутся пустые значения.
Средняя конверсия 5,67% при настоящей 2,05%
Шестая ошибка живёт уже не в самом SQL, а в том, что на нём удобно посчитать неправильно. Три рекламные кампании сильно разного размера:
CREATE TABLE campaigns (name VARCHAR, clicks INT, conversions INT); INSERT INTO campaigns VALUES ('Поиск', 100000, 2000), ('РСЯ', 1000, 50), ('Ретаргет', 200, 20);
Конверсия по каждой кампании считается очевидно: 2%, 5% и 10%. А дальше нужно одно число за весь канал, и вот тут развилка:
SELECT round(AVG(conversions * 100.0 / clicks), 2) AS srednee_iz_procentov, round(SUM(conversions) * 100.0 / SUM(clicks), 2) AS procent_iz_summ FROM campaigns;
srednee_iz_procentov procent_iz_summ 5.67 2.05
Разница почти втрое, и оба числа посчитаны без единой ошибки. Первое — среднее арифметическое трёх процентов, где двухсоткликовый ретаргет весит столько же, сколько стотысячный поиск. Второе — настоящая конверсия канала: две тысячи семьдесят конверсий на сто одну тысячу двести кликов.
Правильное почти всегда второе, потому что вопрос «какая у нас конверсия» означает «сколько из всех кликов конвертировалось», а не «какое среднее у наших кампаний». Среднее из процентов имеет смысл тогда, когда вы сознательно даёте всем кампаниям равный вес. Например, оцениваете, насколько ровно они настроены, а не сколько денег принёс канал.
Опознаётся ошибка по форме: если внутри агрегатной функции стоит деление, почти наверняка что‑то не так. Правильная форма — деление снаружи, между двумя агрегатами. То же самое относится к средним ценам, к средней марже, к среднему чеку по группам и вообще ко всему, что считается как отношение.
BETWEEN на метках времени теряет последний день
Осталась последняя ошибка, из той же семьи и едва ли не самая частая. Есть события с точным временем:
CREATE TABLE events (user_id INT, ts TIMESTAMP, amount INT); INSERT INTO events VALUES (1,'2026-08-31 21:30:00',100), (2,'2026-08-31 23:45:00',200), (3,'2026-09-01 02:10:00',300), (4,'2026-09-01 10:00:00',400);
Просим за два дня, с 31 августа по 1 сентября включительно:
SELECT COUNT(*), SUM(amount) FROM events WHERE ts BETWEEN '2026-08-31' AND '2026-09-01'; SELECT COUNT(*), SUM(amount) FROM events WHERE ts >= '2026-08-31' AND ts < '2026-09-02';
BETWEEN дат: 2 события, 300 полуинтервал: 4 события, 1000
BETWEEN не соврал, он сработал буквально: правая граница '2026-09-01' доросла до полуночи этого дня, то есть до 2026-09-01 00:00:00. Всё, что случилось первого числа после полуночи, в диапазон не попало, и отчёт потерял больше половины суммы. Работает BETWEEN только там, где колонка действительно дата, а не метка времени.
Рядом лежит и вторая половина той же беды — часовой пояс. Те же четыре события, сгруппированные по дате в UTC и по дате в московском времени:
UTC: 31 августа -> 300 | 1 сентября -> 700 МСК: 1 сентября -> 1000
Два разных отчёта, оба правильные, разница в триста рублей на границе суток. Вечерние события 31 августа по московскому времени случились уже первого сентября, и месячный отчёт за август в одном варианте включает их, а в другом нет. Договариваться о часовом поясе надо один раз и на весь отчёт, а не на каждый запрос отдельно.
SQL отвечает на тот вопрос, который вы написали
SQL не спрашивает, что вы имели в виду, и не сообщает, что вопрос вышел не тот, который вы хотели задать: он берёт написанное буквально и возвращает ответ.
Из этого следует единственная привычка, которая помогает: проверять агрегаты на количестве строк до и после. Если после присоединения таблицы строк стало больше, любая сумма по левой таблице испорчена. Если сумма групп меньше общего итога, где‑то потерялся NULL. Если два отчёта на одну метрику расходятся, первым делом сравниваются не формулы, а COUNT(*) промежуточных выборок.
Все запросы и весь вывод получены в DuckDB 1.5.5.

В работе с данными важно не только написать запрос, который выполнится, но и убедиться, что он отвечает на правильный вопрос. На открытых уроках разберём, как проектировать работу с данными и находить проблемы, из‑за которых отчёты могут показывать неверные выводы. Можно будет посмотреть, как проходит обучение, задать вопросы экспертам и глубже разобраться в темах, которые часто встречаются в работе с аналитическими системами.
15 сентября, 20:00. «Моделирование данных для DWH». Записаться
30 сентября, 20:00. «Качество данных: риски и ответственность». Записаться
Полный список бесплатных уроков сентября смотрите в дайджесте.

