Ошибки в SQL — антипаттерны и как их избежать
Коротко
Даже опытные аналитики делают одни и те же ошибки в SQL. Этот гайд — подборка из 15 самых частых антипаттернов с объяснениями, почему это плохо и как правильно. На собеседованиях умение видеть ошибки в чужом запросе ценится не меньше, чем умение писать свой.
1. SELECT * в продакшене
-- Плохо
SELECT * FROM orders WHERE user_id = 42;
-- Хорошо
SELECT order_id, amount, order_date, status
FROM orders
WHERE user_id = 42;SELECT * тянет все столбцы, включая ненужные BLOB, JSON, текстовые поля. Замедляет запрос, увеличивает трафик. При изменении схемы таблицы запрос может сломаться. Используйте для быстрой отладки, не в финальном запросе.
2. Целочисленное деление
-- Плохо: 5 / 2 = 2 (не 2.5!)
SELECT
COUNT(CASE WHEN converted THEN 1 END) / COUNT(*) AS conversion
FROM users;
-- Хорошо
SELECT
COUNT(CASE WHEN converted THEN 1 END)::NUMERIC / COUNT(*) AS conversion
FROM users;Деление двух INTEGER даёт INTEGER. Приведите хотя бы один к NUMERIC через CAST.
3. Агрегат в WHERE
-- Плохо: ошибка синтаксиса
SELECT user_id, COUNT(*) AS orders
FROM orders
WHERE COUNT(*) >= 5
GROUP BY user_id;
-- Хорошо: HAVING
SELECT user_id, COUNT(*) AS orders
FROM orders
GROUP BY user_id
HAVING COUNT(*) >= 5;WHERE выполняется до GROUP BY, агрегатные функции там недопустимы. Для фильтрации после группировки — HAVING.
4. NOT IN с NULL
-- Плохо: вернёт 0 строк если в подзапросе есть NULL!
SELECT * FROM users
WHERE user_id NOT IN (SELECT referred_by FROM users);
-- Хорошо: NOT EXISTS
SELECT * FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM users u2 WHERE u2.referred_by = u.user_id
);
-- Или фильтрация NULL
SELECT * FROM users
WHERE user_id NOT IN (
SELECT referred_by FROM users WHERE referred_by IS NOT NULL
);NOT IN с NULL в списке всегда возвращает пустой результат. Это самая коварная ловушка SQL. EXISTS безопаснее.
5. JOIN без условия (декартово произведение)
-- Плохо: 1000 × 500 = 500 000 строк
SELECT * FROM orders, users;
-- Хорошо
SELECT * FROM orders o
JOIN users u ON o.user_id = u.user_id;Без ON или WHERE каждая строка одной таблицы соединяется с каждой строкой другой. Результат — декартово произведение.
6. JOIN с дублирующим ключом
-- Данные размножаются при one-to-many JOIN
SELECT
u.user_id,
u.name,
SUM(o.amount) AS total -- неверно, если JOIN с ещё одной таблицей
FROM users u
JOIN orders o ON u.user_id = o.user_id
JOIN payments p ON u.user_id = p.user_id -- размножает строки!
GROUP BY u.user_id, u.name;
-- Хорошо: агрегировать ДО join
WITH user_orders AS (
SELECT user_id, SUM(amount) AS total_orders FROM orders GROUP BY user_id
),
user_payments AS (
SELECT user_id, SUM(amount) AS total_payments FROM payments GROUP BY user_id
)
SELECT u.name, uo.total_orders, up.total_payments
FROM users u
LEFT JOIN user_orders uo ON u.user_id = uo.user_id
LEFT JOIN user_payments up ON u.user_id = up.user_id;JOIN двух one-to-many таблиц через общий ключ создаёт «веер» (fan-out). Суммы удваиваются. Агрегируйте до JOIN через CTE.
7. COUNT(column) вместо COUNT(*)
-- Коварно: COUNT(email) пропускает NULL
SELECT COUNT(email) FROM users; -- 950 (50 пользователей без email)
SELECT COUNT(*) FROM users; -- 1000
-- Когда нужно количество строк — COUNT(*)
-- Когда нужно количество непустых — COUNT(column)COUNT(column) игнорирует NULL. COUNT(*) считает все строки. Подробнее — COUNT(*) vs COUNT(column).
8. ORDER BY в подзапросе
-- Бесполезно: ORDER BY в подзапросе игнорируется
SELECT * FROM (
SELECT * FROM orders ORDER BY amount DESC
) sub
LIMIT 10;
-- Хорошо: ORDER BY во внешнем запросе
SELECT * FROM orders
ORDER BY amount DESC
LIMIT 10;Порядок строк в подзапросе не гарантируется (за исключением LIMIT). ORDER BY имеет смысл только в финальном SELECT.
9. DISTINCT вместо исправления JOIN
-- Плохо: DISTINCT скрывает проблему дубликатов
SELECT DISTINCT u.user_id, u.name
FROM users u
JOIN orders o ON u.user_id = o.user_id;
-- Хорошо: понять, почему дубликаты, и исправить запрос
SELECT u.user_id, u.name
FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.user_id);DISTINCT — «пластырь». Если в результате дубликаты — разберитесь с JOIN, а не маскируйте DISTINCT.
10. LIKE без индекса
-- Медленно: начинается с % → полный скан таблицы
SELECT * FROM products WHERE name LIKE '%ноутбук%';
-- Быстрее: LIKE с началом строки использует индекс
SELECT * FROM products WHERE name LIKE 'ноутбук%';
-- Для полнотекстового поиска — специальные инструменты
-- PostgreSQL: tsvector + tsquery
-- MySQL: FULLTEXT INDEXLIKE с % в начале не может использовать B-tree индекс. Для поиска подстроки нужен полнотекстовый индекс или триграммный.
11. Неправильный GROUP BY
-- Плохо: name не в GROUP BY и не в агрегате
SELECT user_id, name, COUNT(*) AS orders
FROM orders
GROUP BY user_id; -- name не агрегирован!
-- Хорошо
SELECT user_id, MAX(name) AS name, COUNT(*) AS orders
FROM orders
GROUP BY user_id;В стандартном SQL все столбцы в SELECT должны быть в GROUP BY или в агрегатной функции. MySQL допускает нестрогий режим — это ловушка.
12. Функция на индексированном столбце
-- Плохо: индекс на created_at не используется
SELECT * FROM orders WHERE EXTRACT(YEAR FROM created_at) = 2025;
-- Хорошо: диапазон использует индекс
SELECT * FROM orders
WHERE created_at >= '2025-01-01' AND created_at < '2026-01-01';Применение функции к столбцу (DATE(), YEAR(), LOWER()) отключает использование индекса. Перепишите условие как диапазон. Подробнее — индексы SQL.
13. UPDATE/DELETE без WHERE
-- Катастрофа
UPDATE users SET role = 'admin'; -- все пользователи — админы
DELETE FROM orders; -- все заказы удалены
-- Безопасно: всегда WHERE + транзакция
BEGIN;
UPDATE users SET role = 'admin' WHERE user_id = 42;
-- проверить → COMMIT или ROLLBACKИспользуйте транзакции для защиты. Привычка: перед UPDATE/DELETE — сначала SELECT с тем же WHERE.
14. OR вместо IN
-- Многословно
SELECT * FROM orders
WHERE status = 'pending' OR status = 'processing' OR status = 'shipped';
-- Лаконично
SELECT * FROM orders
WHERE status IN ('pending', 'processing', 'shipped');IN — читабельнее и часто быстрее. Оптимизатор может обработать IN эффективнее цепочки OR.
15. Подзапрос в SELECT для каждой строки
-- Плохо: подзапрос выполняется для КАЖДОЙ строки
SELECT
u.user_id,
u.name,
(SELECT COUNT(*) FROM orders o WHERE o.user_id = u.user_id) AS order_count
FROM users u;
-- Хорошо: LEFT JOIN + GROUP BY
SELECT u.user_id, u.name, COUNT(o.order_id) AS order_count
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id
GROUP BY u.user_id, u.name;Коррелированный подзапрос в SELECT — O(n×m). JOIN — обычно быстрее благодаря оптимизатору.
Вопросы с собеседований
-- Почему NOT IN с NULL возвращает пустой результат?
-- Потому что x NOT IN (1, NULL) → x != 1 AND x != NULL → x != 1 AND UNKNOWN → UNKNOWN. Ни одна строка не проходит.
-- Как избежать дублирования при JOIN двух таблиц? -- Агрегировать до JOIN через CTE. Или использовать EXISTS вместо JOIN, если нужна только проверка наличия.
-- SELECT * — почему плохо? -- Тянет лишние данные, ломается при изменении схемы, не документирует, какие столбцы нужны. В продакшене всегда перечисляйте столбцы явно.
Потренируйтесь решать задачи — откройте тренажёр с 1500+ вопросами для подготовки к собеседованиям аналитиков.
FAQ
Как найти медленные запросы?
EXPLAIN ANALYZE — покажет план выполнения и время каждого шага. Ищите Seq Scan на больших таблицах, Nested Loop с большим количеством строк, Sort на данных без индекса.
Какие ошибки чаще всего на собеседованиях?
Целочисленное деление, NULL в NOT IN, агрегат в WHERE, дублирование при JOIN. Если видите эти ловушки — вы впереди 80% кандидатов.
Как тренироваться
Задачи на SQL с ловушками — в тренажёре Карьерник. Больше вопросов — в разделе с примерами.