Глава 4 из 28
4. Запросы: SELECT, фильтры, сортировка, NULL
Почти всё, что вы делаете с базой, — это SELECT. В этой главе разберём его основные части и самую коварную особенность SQL: NULL. Именно на NULL ошибаются чаще всего, потому что он ведёт себя не так, как «пустое значение» в языках программирования.
Данные для примеров: 8 клиентов и 12 заказов. Скрипт создания лежит в конце главы, примеры запускаются кнопкой «Выполнить». У двух клиентов нет заказов, у двух не указан город, а у части клиентов есть пригласивший.
Порядок записи и порядок выполнения
Запрос пишут в одном порядке, а выполняет база в другом:
SELECT ... -- 5. что показать
FROM ... -- 1. откуда брать строки
WHERE ... -- 2. какие строки оставить
GROUP BY ... -- 3. как сгруппировать
HAVING ... -- 4. какие группы оставить
ORDER BY ... -- 6. как отсортировать
LIMIT ... -- 7. сколько вернуть
Из этого следует то, что часто удивляет новичков: псевдоним, заданный в SELECT, в WHERE ещё не существует. Он пригоден только в ORDER BY.
Выбор колонок и выражения
В SELECT можно не только перечислять колонки, но и вычислять новые значения. Псевдоним задаётся словом AS:
SELECT id,
amount,
amount * 0.2 AS vat,
upper(status) AS status_up
FROM orders
WHERE id <= 3
ORDER BY id;
id | amount | vat | status_up
----+---------+---------+-----------
1 | 1500.00 | 300.000 | PAID
2 | 320.50 | 64.100 | PAID
3 | 99.90 | 19.980 | PAID
В рабочих запросах не используйте SELECT *. Список колонок защищает от неожиданностей, когда в таблицу добавят новую колонку, и не гоняет по сети лишние данные.
WHERE: условия
Основные операторы: =, <> (не равно), <, >, <=, >=. Для диапазонов, списков и шаблонов есть сокращения.
SELECT id, customer_id, status, amount
FROM orders
WHERE status IN ('paid', 'shipped')
AND amount BETWEEN 500 AND 2000
ORDER BY id;
id | customer_id | status | amount
----+-------------+---------+---------
1 | 1 | paid | 1500.00
8 | 5 | shipped | 640.00
10 | 1 | shipped | 990.00
BETWEEN 500 AND 2000 включает обе границы: это amount >= 500 AND amount <= 2000. Для дат и времени на границах BETWEEN часто даёт ошибку на единицу, поэтому там надёжнее писать >= начало AND < конец.
Поиск по тексту делают через LIKE (знак % — любое количество символов, _ — один символ) и ILIKE (то же без учёта регистра):
SELECT id, name FROM customers WHERE name ILIKE '%а' ORDER BY id;
id | name
----+-------
1 | Анна
3 | Вера
7 | Жанна
Учтите, что условие LIKE '%текст' с процентом в начале не использует обычный индекс: база просматривает всю таблицу.
Приоритет AND и OR
У AND приоритет выше, чем у OR. Запрос без скобок читается не так, как кажется:
SELECT id, status, amount
FROM orders
WHERE status = 'new' OR status = 'paid' AND amount > 1000
ORDER BY id;
id | status | amount
----+--------+---------
1 | paid | 1500.00
5 | new | 800.00
6 | paid | 2300.00
9 | new | 75.50
Условие понимается как status = 'new' OR (status = 'paid' AND amount > 1000). Заказы со статусом new попадут в результат при любой сумме. Если вы хотели другое, расставьте скобки явно:
SELECT id, status, amount
FROM orders
WHERE (status = 'new' OR status = 'paid') AND amount > 1000
ORDER BY id;
id | status | amount
----+--------+---------
1 | paid | 1500.00
6 | paid | 2300.00
Сортировка и ограничение выдачи
Сортировка по нескольким колонкам: сначала по первой, при равенстве по второй. Направление задаётся для каждой колонки отдельно.
SELECT id, status, amount
FROM orders
ORDER BY status ASC, amount DESC;
id | status | amount
----+-----------+---------
4 | cancelled | 450.00
12 | cancelled | 60.00
5 | new | 800.00
9 | new | 75.50
6 | paid | 2300.00
1 | paid | 1500.00
2 | paid | 320.50
11 | paid | 210.00
7 | paid | 120.00
3 | paid | 99.90
10 | shipped | 990.00
8 | shipped | 640.00
Без ORDER BY порядок строк не определён. Он может совпасть с порядком вставки, а может и нет, и меняется после UPDATE и очистки. Если порядок важен, пишите ORDER BY всегда.
LIMIT ограничивает число строк, а OFFSET пропускает первые. Вместе они дают постраничный вывод:
SELECT id, amount FROM orders ORDER BY amount DESC LIMIT 3 OFFSET 3;
id | amount
----+--------
5 | 800.00
8 | 640.00
4 | 450.00
Это четвёртая, пятая и шестая по величине суммы. Но OFFSET на больших значениях медленный: чтобы пропустить 100 000 строк, база сначала их находит и отбрасывает. Для длинных лент и каталогов применяют «keyset pagination»: запоминают последнее значение и просят WHERE amount < последнее.
Если LIMIT стоит без ORDER BY, вы получите произвольные строки, и при повторном запуске они могут оказаться другими.
DISTINCT: убрать повторы
SELECT DISTINCT status FROM orders ORDER BY status;
status
-----------
cancelled
new
paid
shipped
DISTINCT сравнивает строки целиком, по всем выбранным колонкам. Часто его добавляют, чтобы «починить» дубли после JOIN, и это маскирует ошибку в запросе. Если дубли появились неожиданно, разберитесь, откуда они взялись.
NULL: неизвестное значение
NULL — это не ноль, не пустая строка и не «ничего». Это «значение неизвестно». Отсюда главное правило: любое сравнение с NULL даёт не истину и не ложь, а NULL.
SELECT NULL = NULL AS equal,
NULL <> 1 AS not_equal,
1 = NULL AS one_equals_null;
equal | not_equal | one_equals_null
-------+-----------+-----------------
| |
WHERE оставляет только строки, где условие истинно. Строки с NULL в условии отбрасываются. Поэтому попытка найти клиентов без города записью = NULL не сработает:
SELECT id, name, city FROM customers WHERE city = NULL;
id | name | city
----+------+------
(0 rows)
Запрос возвращает пустой результат. Для проверки есть специальные операторы:
SELECT id, name, city FROM customers WHERE city IS NULL ORDER BY id;
id | name | city
----+-------+------
4 | Глеб |
8 | Игорь |
Обратная проверка — IS NOT NULL.
Условие «не равно» тоже теряет NULL
Выберем клиентов не из Москвы:
SELECT id, name, city FROM customers WHERE city <> 'Москва' ORDER BY id;
id | name | city
----+-------+--------
2 | Борис | Казань
5 | Дарья | Казань
7 | Жанна | Тверь
В результате нет клиентов с неизвестным городом: условие NULL <> 'Москва' — это NULL, а не истина. Если «не из Москвы» должно включать и тех, у кого города нет, учитывайте NULL явно:
SELECT id, name, city
FROM customers
WHERE city IS DISTINCT FROM 'Москва'
ORDER BY id;
id | name | city
----+-------+--------
2 | Борис | Казань
4 | Глеб |
5 | Дарья | Казань
7 | Жанна | Тверь
8 | Игорь |
Оператор IS DISTINCT FROM сравнивает как обычное «не равно», но считает NULL обычным значением. Парный оператор — IS NOT DISTINCT FROM.
NOT IN и NULL: самая опасная ловушка
У клиентов есть колонка referred_by: кто пригласил клиента, а у тех, кто пришёл сам, она пустая. Найдём клиентов, которые никого не пригласили. Сначала запрос через NOT IN, где NULL из подзапроса мы убрали руками:
SELECT id, name
FROM customers
WHERE id NOT IN (SELECT referred_by FROM customers WHERE referred_by IS NOT NULL)
ORDER BY id;
id | name
----+-------
3 | Вера
4 | Глеб
5 | Дарья
6 | Егор
7 | Жанна
8 | Игорь
Теперь тот же запрос без фильтра. Он выглядит проще, но в списке из подзапроса оказываются NULL:
SELECT id, name
FROM customers
WHERE id NOT IN (SELECT referred_by FROM customers)
ORDER BY id;
id | name
----+------
(0 rows)
Второй запрос вернул ноль строк, хотя те, кто никого не приглашал, явно есть. Причина в том, что id NOT IN (1, 2, NULL) раскрывается в id <> 1 AND id <> 2 AND id <> NULL, а последнее условие неизвестно, поэтому всё выражение никогда не станет истинным. Одна строка с NULL в подзапросе обнуляет весь результат, и никакой ошибки вы не увидите.
Безопасная замена — NOT EXISTS, он NULL не боится:
SELECT c.id, c.name
FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM customers r WHERE r.referred_by = c.id)
ORDER BY c.id;
id | name
----+-------
3 | Вера
4 | Глеб
5 | Дарья
6 | Егор
7 | Жанна
8 | Игорь
COALESCE: подставить значение вместо NULL
COALESCE(a, b, ...) возвращает первое значение, которое не NULL:
SELECT id, name, COALESCE(city, 'не указан') AS city_label
FROM customers
ORDER BY id;
id | name | city_label
----+-------+------------
1 | Анна | Москва
2 | Борис | Казань
3 | Вера | Москва
4 | Глеб | не указан
5 | Дарья | Казань
6 | Егор | Москва
7 | Жанна | Тверь
8 | Игорь | не указан
Сортировка и NULL
В PostgreSQL при сортировке по возрастанию NULL считается самым большим значением и идёт в конце, а при убывании оказывается первым. Это легко поменять:
SELECT id, name, city FROM customers ORDER BY city DESC NULLS LAST, id;
id | name | city
----+-------+--------
7 | Жанна | Тверь
1 | Анна | Москва
3 | Вера | Москва
6 | Егор | Москва
2 | Борис | Казань
5 | Дарья | Казань
4 | Глеб |
8 | Игорь |
CASE: условная логика внутри запроса
CASE работает как if/else и возвращает значение:
SELECT id, amount,
CASE
WHEN amount >= 1000 THEN 'крупный'
WHEN amount >= 300 THEN 'средний'
ELSE 'мелкий'
END AS size
FROM orders
ORDER BY id;
id | amount | size
----+---------+---------
1 | 1500.00 | крупный
2 | 320.50 | средний
3 | 99.90 | мелкий
4 | 450.00 | средний
5 | 800.00 | средний
6 | 2300.00 | крупный
7 | 120.00 | мелкий
8 | 640.00 | средний
9 | 75.50 | мелкий
10 | 990.00 | средний
11 | 210.00 | мелкий
12 | 60.00 | мелкий
Условия проверяются сверху вниз, срабатывает первое подходящее. Без ELSE для неподходящих строк вернётся NULL.
Частые ошибки
- Писать
= NULLи<> NULL. Эти условия никогда не истинны. ИспользуйтеIS NULL,IS NOT NULL,IS DISTINCT FROM. - Не замечать, что
<>иNOT INотбрасываютNULL. Строки с неизвестным значением в результате пропадают без ошибки. - Использовать
NOT INс подзапросом по колонке, где бываютNULL. ОдинNULLобнуляет результат. БеритеNOT EXISTS. - Забывать скобки вокруг
ORвместе сAND.ANDсвязывается сильнее и меняет смысл условия. LIMITбезORDER BY. Вернутся произвольные строки.- Лечить дубли через
DISTINCT. Это скрывает проблему в запросе, а не решает её. - Использовать
SELECT *в приложении. Структура таблицы изменится, а запрос поведёт себя иначе.
Выборка, сортировка и работа с NULL разобраны на заданиях в курсе «SQL с нуля».
Данные для примеров
CREATE TABLE customers (id int PRIMARY KEY, name text NOT NULL, city text, referred_by int);
INSERT INTO customers VALUES
(1, 'Анна', 'Москва', NULL), (2, 'Борис', 'Казань', 1), (3, 'Вера', 'Москва', 1), (4, 'Глеб', NULL, NULL),
(5, 'Дарья', 'Казань', 2), (6, 'Егор', 'Москва', NULL), (7, 'Жанна', 'Тверь', NULL), (8, 'Игорь', NULL, NULL);
CREATE TABLE orders (
id int PRIMARY KEY, customer_id int NOT NULL REFERENCES customers (id),
status text NOT NULL, amount numeric(10, 2) NOT NULL,
created_at date NOT NULL
);
INSERT INTO orders VALUES
(1, 1, 'paid', 1500.00, '2026-09-03'),
(2, 1, 'paid', 320.50, '2026-09-15'),
(3, 2, 'paid', 99.90, '2026-09-20'),
(4, 2, 'cancelled', 450.00, '2026-09-21'),
(5, 3, 'new', 800.00, '2026-10-01'),
(6, 3, 'paid', 2300.00, '2026-10-02'),
(7, 5, 'paid', 120.00, '2026-10-03'),
(8, 5, 'shipped', 640.00, '2026-10-05'),
(9, 6, 'new', 75.50, '2026-10-07'),
(10, 1, 'shipped', 990.00, '2026-10-09'),
(11, 4, 'paid', 210.00, '2026-10-10'),
(12, 6, 'cancelled', 60.00, '2026-10-11');
Откройте остальные главы
Ещё 20 глав: внутреннее устройство, оптимизация, администрирование. Покупка разовая, доступ навсегда.