SELECT выбирает столбцы, FROM — таблицу, WHERE оставляет строки, для которых условие истинно, ORDER BY сортирует, а LIMIT и OFFSET отрезают нужную часть результата. Сравнение с NULL через = или <> даёт не «истину» или «ложь», а NULL, поэтому такие строки в выборку не попадают — для них есть IS NULL и IS DISTINCT FROM. Без ORDER BY порядок строк не гарантирован, поэтому LIMIT без сортировки возвращает непредсказуемый набор.
После материала вы сможете
- поднять учебную базу PostgreSQL 17 в Docker и выполнять запросы в
psql - написать
SELECTс нужными столбцами, вычислениями и псевдонимамиAS - отфильтровать строки через
WHEREсAND/OR,IN,BETWEEN,LIKE/ILIKEиIS NULLи объяснить, почему= NULLничего не находит - отсортировать результат
ORDER BYи вывести страницу черезLIMITиOFFSET, зная порядок выполнения частей запроса
Что знать заранее
Проверено на
PostgreSQL 17 (17.11, образ postgres:17-alpine)psql 17Docker Engine 29
Сверено с документацией: 11 октября 2026 г.
Содержание
Учебная база в одну команду
Все уроки траектории «SQL и PostgreSQL» работают на одной учебной базе небольшого интернет-магазина кофе и чая. В ней четыре таблицы, знакомые по уроку об основах баз данных: clients (клиенты), products (товары), orders (заказы) и order_items (позиции заказов). Данные вымышленные, имена и e-mail — заглушки на example.com.
Сохраните скрипт ниже в файл shop.sql. Он удаляет и заново создаёт таблицы, поэтому его можно запускать повторно: если вы что-то сломали в упражнениях, база вернётся к исходному состоянию.
-- Training database of an online store: clients, products, orders, order_items
-- Safe to re-run: the tables are recreated from scratch.
DROP TABLE IF EXISTS order_items, orders, products, clients;
CREATE TABLE clients (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
city text,
email text UNIQUE,
created_on date NOT NULL DEFAULT current_date
);
CREATE TABLE products (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
category text NOT NULL,
price numeric(10, 2) NOT NULL CHECK (price > 0),
weight_g integer CHECK (weight_g > 0)
);
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
client_id bigint NOT NULL REFERENCES clients (id),
ordered_on date NOT NULL,
status text NOT NULL
CHECK (status IN ('new', 'paid', 'shipped', 'cancelled')),
promo_code text
);
CREATE TABLE order_items (
order_id bigint NOT NULL REFERENCES orders (id) ON DELETE CASCADE,
product_id bigint NOT NULL REFERENCES products (id),
quantity integer NOT NULL CHECK (quantity > 0),
price numeric(10, 2) NOT NULL CHECK (price > 0),
PRIMARY KEY (order_id, product_id)
);
INSERT INTO clients (name, city, email, created_on) VALUES
('Aigerim Seitova', 'Almaty', 'aigerim@example.com', '2026-06-02'), -- id 1
('Daniyar Akhmetov', 'Astana', NULL, '2026-06-15'), -- id 2
('Madina Omarova', 'Almaty', 'madina@example.com', '2026-07-01'), -- id 3
('Timur Kim', 'Shymkent', 'timur@example.com', '2026-07-20'), -- id 4
('Aliya Bekova', NULL, 'aliya@example.com', '2026-08-05'), -- id 5
('Ruslan Ivanov', 'Almaty', NULL, '2026-08-28'), -- id 6
('Zarina Kaliyeva', 'Karaganda', 'zarina@example.org', '2026-09-10'), -- id 7
('Sergey Pak', 'Astana', 'sergey@example.com', '2026-09-30'); -- id 8
INSERT INTO products (name, category, price, weight_g) VALUES
('Espresso Blend 1 kg', 'coffee', 12900, 1000), -- id 1
('Colombia Supremo 250 g', 'coffee', 4900, 250), -- id 2
('Ethiopia Yirgacheffe 250 g', 'coffee', 5600, 250), -- id 3
('Green Sencha 100 g', 'tea', 3200, 100), -- id 4
('Masala Chai 200 g', 'tea', 3800, 200), -- id 5
('Ceramic Mug 350 ml', 'tableware', 2500, NULL), -- id 6
('French Press 600 ml', 'equipment', 8900, NULL), -- id 7
('Hand Grinder', 'equipment', 18500, NULL), -- id 8
('Gift Box', 'gifts', 15000, NULL); -- id 9
INSERT INTO orders (client_id, ordered_on, status, promo_code) VALUES
(1, '2026-09-02', 'shipped', NULL), -- id 1
(2, '2026-09-05', 'shipped', 'AUTUMN10'), -- id 2
(1, '2026-09-14', 'paid', NULL), -- id 3
(3, '2026-09-20', 'cancelled', NULL), -- id 4
(4, '2026-09-28', 'shipped', NULL), -- id 5
(5, '2026-10-01', 'paid', 'AUTUMN10'), -- id 6
(3, '2026-10-03', 'shipped', NULL), -- id 7
(6, '2026-10-05', 'new', NULL), -- id 8
(1, '2026-10-08', 'paid', 'VIP'), -- id 9
(2, '2026-10-09', 'new', NULL); -- id 10: no items yet
-- price is the price at order time: the catalogue may change, the order must not
INSERT INTO order_items (order_id, product_id, quantity, price) VALUES
(1, 1, 1, 12900), (1, 6, 2, 2500),
(2, 2, 2, 4900), (2, 4, 1, 3200),
(3, 7, 1, 8900),
(4, 8, 1, 18500),
(5, 3, 1, 5600), (5, 5, 2, 3800), (5, 6, 1, 2500),
(6, 1, 2, 12900),
(7, 2, 1, 4900), (7, 3, 1, 5600),
(8, 4, 3, 3200),
(9, 8, 1, 18500), (9, 1, 1, 12900);Понадобится установленный Docker. Образ postgres:17-alpine — официальный образ PostgreSQL 17 на Alpine Linux; на момент проверки это версия 17.11. Переменная POSTGRES_PASSWORD обязательна для образа. Мы не публикуем порт через -p, поэтому база недоступна снаружи контейнера, а пароль test годится только для такого локального учебного запуска.
# 1. Start PostgreSQL 17 in a container (it is removed when stopped)
docker run -d --rm --name shop-db -e POSTGRES_PASSWORD=test postgres:17-alpine
# 2. Wait a few seconds, then load the training database
# (run from the folder that contains shop.sql)
docker exec -i shop-db psql -U postgres < shop.sql
# 3. Open psql; NULL values are printed as NULL
docker exec -it shop-db psql -U postgres -P null=NULLЕсли выполнить второй шаг сразу после первого, сервер ещё может инициализироваться — тогда psql ответит ошибкой подключения. Подождите несколько секунд и повторите команду:
$ docker exec -i shop-db psql -U postgres < shop.sql
psql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed: No such file or directory
Is the server running locally and accepting connections on that socket?В PowerShell оператор < не поддерживается, поэтому второй шаг выглядит так:
Get-Content shop.sql | docker exec -i shop-db psql -U postgres$ docker exec -i shop-db psql -U postgres < shop.sql
NOTICE: table "order_items" does not exist, skipping
NOTICE: table "orders" does not exist, skipping
NOTICE: table "products" does not exist, skipping
NOTICE: table "clients" does not exist, skipping
DROP TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 8
INSERT 0 9
INSERT 0 10
INSERT 0 15Третья команда открывает интерактивный psql. Параметр -P null=NULL просит печатать NULL словом: по умолчанию psql выводит пустоту, и документация отмечает, что её легко спутать с пустой строкой. Все результаты в уроке получены именно с этой настройкой. Команда \dt показывает таблицы, \q — выход, а docker stop shop-db останавливает и удаляет контейнер вместе с данными.
postgres=# \dt
List of relations
Schema | Name | Type | Owner
--------+-------------+-------+----------
public | clients | table | postgres
public | order_items | table | postgres
public | orders | table | postgres
public | products | table | postgres
(4 rows)SELECT: какие столбцы вернуть
Запрос SELECT состоит из списка выбора (какие столбцы вернуть), списка таблиц после FROM и необязательного условия. Звёздочка означает «все столбцы». Каждая команда SQL заканчивается точкой с запятой — без неё psql ждёт продолжения.
SELECT * FROM products;1
- name
- Espresso Blend 1 kg
- category
- coffee
- price
- 12900.00
- weight_g
- 1000
2
- name
- Colombia Supremo 250 g
- category
- coffee
- price
- 4900.00
- weight_g
- 250
3
- name
- Ethiopia Yirgacheffe 250 g
- category
- coffee
- price
- 5600.00
- weight_g
- 250
4
- name
- Green Sencha 100 g
- category
- tea
- price
- 3200.00
- weight_g
- 100
5
- name
- Masala Chai 200 g
- category
- tea
- price
- 3800.00
- weight_g
- 200
6
- name
- Ceramic Mug 350 ml
- category
- tableware
- price
- 2500.00
- weight_g
- NULL
7
- name
- French Press 600 ml
- category
- equipment
- price
- 8900.00
- weight_g
- NULL
8
- name
- Hand Grinder
- category
- equipment
- price
- 18500.00
- weight_g
- NULL
9
- name
- Gift Box
- category
- gifts
- price
- 15000.00
- weight_g
- NULL
В списке выбора можно перечислить нужные столбцы и написать выражения. AS задаёт имя столбца в результате — псевдоним. Функция round округляет число, а строка в одинарных кавычках — это литерал, одинаковый для всех строк.
SELECT name,
price,
round(price * 0.9) AS price_with_10_off,
'KZT' AS currency
FROM products;Espresso Blend 1 kg
- price
- 12900.00
- price_with_10_off
- 11610
- currency
- KZT
Colombia Supremo 250 g
- price
- 4900.00
- price_with_10_off
- 4410
- currency
- KZT
Ethiopia Yirgacheffe 250 g
- price
- 5600.00
- price_with_10_off
- 5040
- currency
- KZT
Green Sencha 100 g
- price
- 3200.00
- price_with_10_off
- 2880
- currency
- KZT
DISTINCT убирает повторяющиеся строки результата. Документация PostgreSQL предупреждает: DISTINCT сам по себе не сортирует, поэтому для предсказуемого порядка добавляют ORDER BY.
SELECT DISTINCT category
FROM products
ORDER BY category;coffee
equipment
gifts
tableware
tea
WHERE: сравнения, AND/OR, IN, BETWEEN
WHERE оставляет только строки, для которых логическое условие истинно. Строки, где условие ложно или равно NULL, отбрасываются. Числа пишут как есть, а строки и даты — в одинарных кавычках.
=, <>
- Значение
- Равно, не равно (
!=— синоним<>) - Пример
status = 'paid'
<, >, <=, >=
- Значение
- Меньше, больше, включая равенство
- Пример
price > 10000
AND, OR, NOT
- Значение
- И, или, не
- Пример
category = 'coffee' AND price < 6000
IN (…)
- Значение
- Равно одному из значений списка
- Пример
status IN ('paid', 'shipped')
BETWEEN a AND b
- Значение
- В диапазоне, границы включены
- Пример
ordered_on BETWEEN '2026-09-01' AND '2026-09-30'
SELECT name, price
FROM products
WHERE price > 10000;Espresso Blend 1 kg
- price
- 12900.00
Hand Grinder
- price
- 18500.00
Gift Box
- price
- 15000.00
Главная ловушка — приоритет операторов: AND связывает сильнее, чем OR. Допустим, нужен кофе или оборудование дешевле 6000 ₸. Запрос без скобок читается базой как «кофе любой цены, или оборудование дешевле 6000»:
-- Intent: coffee or equipment cheaper than 6000
SELECT name, category, price
FROM products
WHERE category = 'coffee' OR category = 'equipment' AND price < 6000;Espresso Blend 1 kg
- category
- coffee
- price
- 12900.00
Colombia Supremo 250 g
- category
- coffee
- price
- 4900.00
Ethiopia Yirgacheffe 250 g
- category
- coffee
- price
- 5600.00
SELECT name, category, price
FROM products
WHERE (category = 'coffee' OR category = 'equipment') AND price < 6000;Colombia Supremo 250 g
- category
- coffee
- price
- 4900.00
Ethiopia Yirgacheffe 250 g
- category
- coffee
- price
- 5600.00
IN заменяет цепочку OR по одному столбцу, а BETWEEN — пару сравнений >= и <=. Обе границы BETWEEN входят в диапазон, это отмечено в документации.
SELECT id, client_id, status
FROM orders
WHERE status IN ('paid', 'shipped');SELECT id, ordered_on, status
FROM orders
WHERE ordered_on BETWEEN '2026-09-01' AND '2026-09-30';1
- ordered_on
- 2026-09-02
- status
- shipped
2
- ordered_on
- 2026-09-05
- status
- shipped
3
- ordered_on
- 2026-09-14
- status
- paid
4
- ordered_on
- 2026-09-20
- status
- cancelled
5
- ordered_on
- 2026-09-28
- status
- shipped
LIKE и ILIKE: поиск по тексту
LIKE сравнивает строку с шаблоном. В шаблоне % означает любую последовательность символов, в том числе пустую, а _ — ровно один любой символ. Шаблон всегда покрывает строку целиком: чтобы найти подстроку где угодно, ставят % с обеих сторон.
SELECT name FROM products WHERE name LIKE '%250 g';Colombia Supremo 250 g
Ethiopia Yirgacheffe 250 g
LIKE различает регистр. ILIKE — расширение PostgreSQL, которого нет в стандарте SQL, — сравнивает без учёта регистра по правилам текущей локали:
SELECT name FROM clients WHERE name LIKE '%ova';
SELECT name FROM clients WHERE name LIKE 'a%';
SELECT name FROM clients WHERE name ILIKE 'a%';
-- name
-- -----------------
-- Aigerim Seitova
-- Madina Omarova
-- Aliya Bekova
-- (3 rows)
--
-- name
-- ------
-- (0 rows)
--
-- name
-- -----------------
-- Aigerim Seitova
-- Aliya Bekova
-- (2 rows)Чтобы искать сами символы % или _, их экранируют обратной косой чертой: LIKE '%\_%' найдёт строки с подчёркиванием.
NULL: почему = NULL не работает
NULL в SQL означает «значение неизвестно». Документация PostgreSQL формулирует правило так: обычные операторы сравнения дают NULL, а не «истину» или «ложь», если хотя бы один операнд равен NULL. Неизвестно, равны ли два неизвестных значения, поэтому даже NULL = NULL — это NULL.
SELECT name, email FROM clients WHERE email = NULL;
-- name | email
-- ------+-------
-- (0 rows)SELECT name, email FROM clients WHERE email IS NULL;Daniyar Akhmetov
- NULL
Ruslan Ivanov
- NULL
SQL использует трёхзначную логику: истина, ложь и NULL («неизвестно»). WHERE пропускает только «истину». Посмотрим, во что превращаются выражения с NULL:
SELECT NULL = NULL AS null_eq_null,
7 <> NULL AS seven_ne_null,
NULL IS NULL AS null_is_null,
true OR NULL AS true_or_null,
false AND NULL AS false_and_null;NULL
- seven_ne_null
- NULL
- null_is_null
- t
- true_or_null
- t
- false_and_null
- f
Отсюда самая частая ошибка: условие «не равно» теряет строки с NULL. Нам нужны клиенты не из Алматы — Aliya Bekova, у которой город не указан, в выборку не попадёт, потому что NULL <> 'Almaty' — это NULL:
SELECT name, city FROM clients WHERE city <> 'Almaty';Daniyar Akhmetov
- city
- Astana
Timur Kim
- city
- Shymkent
Zarina Kaliyeva
- city
- Karaganda
Sergey Pak
- city
- Astana
SELECT name, city FROM clients WHERE city IS DISTINCT FROM 'Almaty';Daniyar Akhmetov
- city
- Astana
Timur Kim
- city
- Shymkent
Aliya Bekova
- city
- NULL
Zarina Kaliyeva
- city
- Karaganda
Sergey Pak
- city
- Astana
Та же ловушка в NOT IN: если в списке есть NULL или проверяемое значение равно NULL, результат — NULL, а не «истина». Запрос WHERE city NOT IN ('Almaty', 'Astana') вернёт только Shymkent и Karaganda, клиентки без города в нём не будет. Документация советует по возможности формулировать условие положительно.
Для вывода вместо NULL подставляют значение по умолчанию функцией coalesce: она возвращает первый аргумент, который не NULL.
SELECT name, coalesce(email, '(no email)') AS email
FROM clients
ORDER BY id;Aigerim Seitova
- aigerim@example.com
Daniyar Akhmetov
- (no email)
Madina Omarova
- madina@example.com
ORDER BY, LIMIT, OFFSET и порядок выполнения запроса
Без ORDER BY база возвращает строки в том порядке, в каком ей быстрее, и он может меняться от запуска к запуску. ORDER BY сортирует по одному или нескольким выражениям: по возрастанию (ASC, по умолчанию) или по убыванию (DESC). Второй ключ сортировки работает внутри одинаковых значений первого.
SELECT name, category, price
FROM products
ORDER BY category, price DESC;Espresso Blend 1 kg
- category
- coffee
- price
- 12900.00
Ethiopia Yirgacheffe 250 g
- category
- coffee
- price
- 5600.00
Colombia Supremo 250 g
- category
- coffee
- price
- 4900.00
Hand Grinder
- category
- equipment
- price
- 18500.00
French Press 600 ml
- category
- equipment
- price
- 8900.00
При сортировке NULL считается больше любого значения: по возрастанию он оказывается в конце, по убыванию — в начале. Поменять это можно явно, NULLS FIRST или NULLS LAST:
SELECT name, city FROM clients ORDER BY city, id;
SELECT name, city FROM clients ORDER BY city DESC, id;
SELECT name, city FROM clients ORDER BY city DESC NULLS LAST, id;Aliya Bekova
- city
- NULL
Timur Kim
- city
- Shymkent
Zarina Kaliyeva
- city
- Karaganda
Daniyar Akhmetov
- city
- Astana
Sergey Pak
- city
- Astana
Aigerim Seitova
- city
- Almaty
Madina Omarova
- city
- Almaty
Ruslan Ivanov
- city
- Almaty
Timur Kim
- city
- Shymkent
Zarina Kaliyeva
- city
- Karaganda
Daniyar Akhmetov
- city
- Astana
Sergey Pak
- city
- Astana
Aigerim Seitova
- city
- Almaty
Madina Omarova
- city
- Almaty
Ruslan Ivanov
- city
- Almaty
Aliya Bekova
- city
- NULL
LIMIT ограничивает число строк, OFFSET пропускает строки перед ними. Так строят постраничный вывод. Документация настаивает: с LIMIT нужен ORDER BY, задающий однозначный порядок, иначе вы получите непредсказуемое подмножество. Поэтому в примере вторым ключом стоит id — на случай двух товаров с одинаковой ценой.
SELECT name, price
FROM products
ORDER BY price DESC, id
LIMIT 3;Hand Grinder
- price
- 18500.00
Gift Box
- price
- 15000.00
Espresso Blend 1 kg
- price
- 12900.00
SELECT name, price
FROM products
ORDER BY price DESC, id
LIMIT 3 OFFSET 3;French Press 600 ml
- price
- 8900.00
Ethiopia Yirgacheffe 250 g
- price
- 5600.00
Colombia Supremo 250 g
- price
- 4900.00
Почему псевдоним из SELECT нельзя использовать в WHERE? Потому что части запроса выполняются не в том порядке, в каком написаны. По описанию команды SELECT в документации сначала вычисляется FROM, затем WHERE отбрасывает строки, потом вычисляется список выбора, затем ORDER BY сортирует результат, и в конце LIMIT/OFFSET отрезают нужную часть.
SELECT name, round(price * 0.9) AS sale_price
FROM products
WHERE sale_price < 5000;
-- ERROR: column "sale_price" does not exist
-- LINE 3: WHERE sale_price < 5000;
-- ^SELECT name, round(price * 0.9) AS sale_price
FROM products
WHERE round(price * 0.9) < 5000
ORDER BY sale_price;Ceramic Mug 350 ml
- sale_price
- 2250
Green Sencha 100 g
- sale_price
- 2880
Masala Chai 200 g
- sale_price
- 3420
Colombia Supremo 250 g
- sale_price
- 4410
Упражнения с ответами
Все упражнения выполняются на учебной базе из первого раздела. Если вы меняли данные, загрузите shop.sql заново. Ответы проверены на PostgreSQL 17.11; ожидаемый вывод — в комментариях к решению.
Следующий шаг — соединить таблицы: вывести заказы вместе с именами клиентов, посчитать выручку по категориям и найти клиентов без заказов. Это тема урока «SQL: JOIN, GROUP BY и агрегаты» на той же учебной базе.
Как проверено4 · пройденосверено 11 октября 2026 г.
- запущено, работает
shop.sql (seed учебной базы)
docker run -d --rm --name kb-pg-db0103 -e POSTGRES_PASSWORD=test postgres:17-alpine (PostgreSQL 17.11); загрузка через Git Bash `docker exec -i … psql -U postgres < shop.sql` и через PowerShell 7.6 `Get-Content shop.sql | docker exec -i …`; повторный запуск пересоздаёт таблицы; вывод загрузки в статье — реальный (load_first.txt)
- запущено, работает
Команды запуска (docker run / docker exec)
последовательность выполнена в отдельном контейнере kb-pg-db0103-setup (имя заменено с shop-db): немедленный docker exec после docker run дал ошибку подключения (вставлена в статью дословно), через ~6 с загрузка прошла; \dt и запрос с -P null=NULL через stdin. Интерактивный режим `-it` с TTY не запускался (нет терминала у агента), команда отличается только флагом -t
- запущено, работает
Запросы урока (24 шт.: SELECT, WHERE, LIKE/ILIKE, NULL, ORDER BY, LIMIT/OFFSET, ошибка псевдонима в WHERE)
каждый запрос — отдельный .sql, выполнен `docker exec -i kb-pg-db0103 psql -U postgres -X -P null=NULL < file`; таблицы и комментарии с выводом собраны в статью скриптом из файлов вывода; сокращённые таблицы помечены в подписи
- запущено, работает
Решения 10 упражнений (+ альтернативное решение упр. 9)
выполнены на свежезагруженной базе, ожидаемый вывод в решениях — реальный; альтернатива `city <> 'Astana' OR city IS NULL` дала те же 6 строк
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.