Ошибки в 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 INDEX

LIKE с % в начале не может использовать 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 != NULLx != 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 с ловушками — в тренажёре Карьерник. Больше вопросов — в разделе с примерами.