SQL легко написать, но трудно написать хорошо. Вот 12 антипаттернов, которые встречаются в реальных кодбазах и замедляют производительность или приводят к неверным результатам.
1. SELECT * в продакшне
-- Плохо
SELECT * FROM users;
-- Хорошо
SELECT id, email, name, created_at FROM users;
Проблемы:
- Передаёт лишние данные по сети
- Ломается при добавлении/удалении столбцов
- Мешает index-only scan (PostgreSQL не может использовать покрывающий индекс)
2. Функция на столбце в WHERE — убивает индекс
-- Плохо: индекс на created_at не используется
WHERE YEAR(created_at) = 2026
WHERE DATE(created_at) = '2026-03-15'
WHERE LOWER(email) = 'user@example.com'
-- Хорошо: сравниваем столбец напрямую
WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'
WHERE created_at::date = '2026-03-15' -- хуже, но иногда допустимо
WHERE email = LOWER('User@Example.Com') -- функция на константе — ок
Правило: не применяйте функции к индексированному столбцу в условии WHERE. Используйте функциональные индексы или переписывайте условие.
-- Функциональный индекс — решение
CREATE INDEX idx_users_email_lower ON users(LOWER(email));
WHERE LOWER(email) = 'user@example.com' -- теперь использует индекс
3. NOT IN с NULL в подзапросе
-- Смертельно опасно: если хоть один user_id = NULL → результат пустой!
SELECT * FROM users
WHERE id NOT IN (SELECT user_id FROM orders);
-- Безопасно
SELECT * FROM users u
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
-- Или с явным исключением NULL
WHERE id NOT IN (SELECT user_id FROM orders WHERE user_id IS NOT NULL);
4. OR вместо IN / UNION ALL
-- Плохо: OR с одним столбцом = план с несколькими сканами
WHERE status = 'active' OR status = 'pending' OR status = 'processing'
-- Хорошо: IN — оптимизатор может использовать Bitmap Index Scan
WHERE status IN ('active', 'pending', 'processing')
-- Плохо: OR по разным столбцам — мешает использованию составного индекса
WHERE (user_id = 1 AND status = 'active')
OR (user_id = 2 AND status = 'pending')
-- Хорошо: UNION ALL
SELECT * FROM orders WHERE user_id = 1 AND status = 'active'
UNION ALL
SELECT * FROM orders WHERE user_id = 2 AND status = 'pending'
5. OFFSET для пагинации на больших таблицах
-- Плохо: читает и выбрасывает 10000 строк
SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 10000;
-- Хорошо: курсорная пагинация (keyset pagination)
SELECT * FROM posts
WHERE created_at < :last_seen_date -- передаём значение последней страницы
ORDER BY created_at DESC
LIMIT 20;
При OFFSET 10000 PostgreSQL обрабатывает 10020 строк и выбрасывает первые 10000. Курсорный метод работает за константное время.
6. COUNT(*) для проверки существования
-- Плохо: считает все строки даже если нужно просто «есть или нет»
IF (SELECT COUNT(*) FROM orders WHERE user_id = 42) > 0 THEN ...
-- Хорошо: EXISTS останавливается после первого совпадения
IF EXISTS (SELECT 1 FROM orders WHERE user_id = 42) THEN ...
7. Декартово произведение (случайный CROSS JOIN)
-- Плохо: забыли JOIN условие — получили N×M строк!
SELECT u.name, o.amount
FROM users u, orders o; -- legacy синтаксис JOIN через запятую
-- Хорошо: явный JOIN
SELECT u.name, o.amount
FROM users u
JOIN orders o ON o.user_id = u.id;
-- Скрытый CROSS JOIN с CTE
WITH all_months AS (SELECT generate_series(1, 12) AS month),
all_products AS (SELECT id FROM products)
SELECT * FROM all_months, all_products;
-- Это тоже CROSS JOIN — возможно умышленный, но проверьте
8. DISTINCT как заплатка для дубликатов
-- Плохо: дубликаты симптом, DISTINCT — не лечение
SELECT DISTINCT u.id, u.name
FROM users u
JOIN orders o ON o.user_id = u.id;
-- Хорошо: понять почему дубли и исправить логику
SELECT u.id, u.name
FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
9. Сортировка без LIMIT (на больших таблицах)
-- Плохо: сортировка миллиона строк чтобы взять первые 10
SELECT * FROM events ORDER BY created_at DESC; -- без LIMIT!
-- Хорошо
SELECT * FROM events ORDER BY created_at DESC LIMIT 100;
Сортировка всей таблицы без LIMIT нагружает память и CPU. Всегда добавляйте LIMIT если нужны лишь первые строки.
10. Вычисление в GROUP BY вместо алиаса
-- Плохо: PostgreSQL вычисляет выражение дважды (в SELECT и GROUP BY)
SELECT DATE_TRUNC('month', created_at), COUNT(*)
FROM orders
GROUP BY DATE_TRUNC('month', created_at); -- дублирование
-- Хорошо: использовать позицию столбца
SELECT DATE_TRUNC('month', created_at), COUNT(*)
FROM orders
GROUP BY 1;
-- Или CTE
WITH monthly AS (SELECT DATE_TRUNC('month', created_at) AS month FROM orders)
SELECT month, COUNT(*) FROM monthly GROUP BY month;
11. N+1 запросов вместо JOIN
-- Плохо (псевдокод): один запрос на пользователя
for user in SELECT * FROM users:
orders = SELECT * FROM orders WHERE user_id = user.id
# 1 + N запросов в цикле
-- Хорошо: один запрос с JOIN
SELECT u.id, u.name, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.name;
N+1 — типичная болезнь ORM. В SQL её эквивалент — подзапросы в SELECT без корреляции с JOIN:
-- Плохо: коррелированный подзапрос в SELECT = N+1 в SQL
SELECT
u.id,
(SELECT COUNT(*) FROM orders WHERE user_id = u.id) AS order_count
FROM users u;
-- Хорошо: LEFT JOIN
SELECT u.id, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id;
12. Сохранение в VARCHAR то что должно быть в нужном типе
-- Плохо: дата как строка
CREATE TABLE events (event_date VARCHAR(10)); -- '2026-03-15'
-- Хорошо: правильный тип
CREATE TABLE events (event_date DATE);
-- Или TIMESTAMPTZ если нужно время с таймзоной
Хранение дат, чисел, JSON как VARCHAR:
- Нельзя использовать функции дат/чисел без приведения
- Нельзя создать нормальный индекс
- Неверная сортировка ('10' < '9' лексикографически)
- Нет валидации на уровне БД
Итоговая шпаргалка
| Антипаттерн | Проблема | Решение |
|---|---|---|
SELECT * | Лишние данные, ломается схема | Перечислить столбцы |
| Функция на индексном столбце | Индекс не используется | Переписать условие |
NOT IN с NULL | Пустой результат | NOT EXISTS |
OR по разным столбцам | Плохой план | UNION ALL или IN |
OFFSET большой | Медленно | Keyset pagination |
COUNT(*) для EXISTS | Лишняя работа | EXISTS |
| CROSS JOIN случайный | Взрыв строк | Явный JOIN ON |
DISTINCT как заплатка | Скрывает проблему | Исправить логику |
| Сортировка без LIMIT | Нагрузка памяти | Добавить LIMIT |
| N+1 запросов | Много круговых обращений | JOIN |
| VARCHAR вместо правильного типа | Неверная сортировка, нет индекса | Правильный тип данных |