SQL · Учебник с нуляГлавнаяК заданиям →

SQL с нуля: от таблицы до первого отчёта

Этот учебник рассчитан на человека, который ещё не писал запросы. Проходите главы по порядку: прочитайте объяснение, выполните пример в pgAdmin, измените одно условие и объясните результат своими словами. После этого переходите к заданиям тренажёра. Не нужно запоминать все команды сразу.

1. Что такое база данных и SQL

Представьте интернет-магазин. У него есть покупатели, товары и заказы. Если хранить всё в одной таблице, имя покупателя придётся повторять в каждом заказе, а исправление адреса превратится в поиск множества строк. В реляционной базе сведения разделяют на связанные таблицы.

Таблица похожа на лист электронной таблицы: столбцы описывают свойства, строки — отдельные записи. В products одна строка описывает один товар. В customers — одного покупателя. У каждого столбца есть тип: число, текст, дата или другой вид данных. База проверяет типы при записи.

SQL — язык, на котором вы описываете, какие данные хотите получить или изменить. Например, «покажи названия и цены пяти товаров». PostgreSQL — программа, которая хранит данные и выполняет SQL. pgAdmin — окно управления этой программой. Открытие pgAdmin само по себе ещё не означает подключения к нужной базе.

Имя базы нашего курса — shopdb. Внутри неё есть схемы: public с учебными таблицами и sandbox для экспериментов. Схема — пространство имён, которое объединяет таблицы и другие объекты. Запись public.products означает «таблица products в схеме public».

SELECT current_database(), current_schema();
SELECT product_id, product_name, price
FROM public.products
ORDER BY product_id
LIMIT 5;

Первый запрос показывает текущую базу и схему. Второй возвращает до пяти товаров с тремя столбцами. Это результат запроса, а не новая сохранённая таблица. Сам SELECT не меняет исходные товары.

Попробуйте: замените LIMIT 5 на LIMIT 3. Количество столбцов должно остаться прежним, а строк станет три.

2. Первый запуск в pgAdmin и чтение ошибок

В дереве слева раскройте Servers → ваш сервер → Databases → shopdb. Нажмите правой кнопкой на shopdb и выберите Query Tool. Сверху находится редактор SQL, снизу — результат и сообщения. Введите один запрос и нажмите кнопку выполнения. Если выделена часть текста, pgAdmin может выполнить только выделение: проверьте его перед запуском.

Каждый законченный SQL-оператор завершайте точкой с запятой. SQL понимает переносы строк и пробелы между словами. Ключевые слова SELECT и FROM можно писать строчными буквами; заглавные буквы в примерах помогают видеть структуру запроса. Комментарий после двух дефисов не выполняется.

-- Это комментарий. Запрос ниже просто возвращает числа.
SELECT 2 + 3 AS result;

Здесь нет FROM: для вычисления выражения таблица не нужна. В результате вы должны увидеть столбец result со значением 5. AS задаёт имя столбца результата.

Если видите relation does not exist, проверьте имя таблицы и подключение именно к shopdb. Column does not exist означает, что PostgreSQL не нашёл столбец: возможно, опечатка или перепутаны кавычки. Syntax error указывает на проблему в записи команды; причина может находиться перед отмеченной позицией, например пропущенная запятая.

Имена таблиц в курсе набраны без двойных кавычек, маленькими буквами. Текстовые значения записывают в одинарных кавычках: 'paid'. Двойные кавычки обозначают имена объектов, а не текст. Если после ошибки в транзакции появляется current transaction is aborted, сначала выполните ROLLBACK, затем исправьте исходный запрос.

Попробуйте: выполните SELECT 2 + 3 AS result; и затем измените выражение на 2 * 3. Объясните, почему имя result не изменилось.

3. Как устроена учебная база

Начните с пяти таблиц: customers — покупатели, products — товары, orders — заказы, order_items — позиции заказа, payments — платежи. Все остальные таблицы можно изучать постепенно. Полный список столбцов доступен в pgAdmin: Schemas → public → Tables → нужная таблица → Columns.

Первичный ключ, PRIMARY KEY, отличает запись от остальных. У customers это customer_id. Два человека могут иметь одинаковое имя, поэтому связывать записи по имени нельзя. Внешний ключ, FOREIGN KEY, ссылается на ключ другой таблицы: orders.customer_id показывает, кто оформил заказ.

Один покупатель может иметь много заказов. Один заказ может содержать несколько товаров. Поэтому order_items хранится отдельно: в ней есть order_id, product_id, количество qty и цена unit_price на момент заказа. Текущая цена products.price может отличаться от исторической цены продажи.

SELECT order_id, customer_id, status
FROM orders
ORDER BY order_id
LIMIT 5;

SELECT order_id, product_id, qty, unit_price
FROM order_items
ORDER BY order_id, product_id
LIMIT 10;

Посмотрите на повторения order_id во второй выборке. Повторяется номер заказа, но это разные позиции. Ключ order_items составлен из order_id и product_id: в этой учебной модели один товар занимает одну строку конкретного заказа, а число экземпляров хранится в qty.

Попробуйте: найдите номер заказа из первой выборки во второй. Не ожидайте, что LIMIT 10 обязательно охватит все пять заказов: у заказов разное число позиций.

4. SELECT: столбцы, выражения и названия

После SELECT перечисляют, что должно попасть в результат. После FROM указывают источник строк. Запятые разделяют столбцы; после последнего столбца запятую не ставят. SELECT * означает «все столбцы»: это удобно для знакомства, но в отчётах лучше перечислять нужные поля явно.

SELECT product_id,
       product_name AS name,
       price,
       price * 0.9 AS discounted_price
FROM products
ORDER BY product_id
LIMIT 5;

Прочитайте запрос: «Из таблицы products взять идентификатор, название, цену и цену, умноженную на 0.9; упорядочить по идентификатору; оставить пять строк». Четвёртый столбец вычисляется при выполнении. В products не появляется новый столбец, а цена товара не меняется.

SQL различает целочисленное и дробное деление. Выражение 5 / 2 возвращает 2, потому что оба числа целые. Для дробного результата используйте 5.0 / 2 или явное приведение 5::numeric / 2. Тип numeric подходит для точных десятичных вычислений, например денег.

DISTINCT убирает повторения целых строк результата. SELECT DISTINCT status FROM orders возвращает разные статусы. Если добавить order_id, строки снова будут отличаться: DISTINCT учитывает сразу все выбранные столбцы.

Попробуйте: выведите уникальные каналы заказов из столбца channel. Затем добавьте status и посмотрите, что теперь уникальна пара значений.

5. WHERE: отбор строк

WHERE проверяет условие для каждой строки источника. В результат проходят строки, для которых условие истинно. Например, price >= 1000 оставляет товары с ценой не меньше тысячи. Знаки сравнения: = равно, <> не равно, > больше, < меньше, >= и <= включают границу.

SELECT product_id, product_name, price
FROM products
WHERE price >= 1000 AND price < 5000
ORDER BY price, product_id
LIMIT 10;

AND требует выполнения обоих условий. OR достаточно одного. NOT отрицает условие. Скобки позволяют выразить замысел явно: (status = 'paid' OR status = 'shipped') AND channel = 'web'. Без скобок AND выполняется раньше OR, и можно случайно получить лишние строки.

IN удобно для списка: status IN ('paid', 'shipped'). BETWEEN 1000 AND 5000 включает обе границы. Для текста нужны одинарные кавычки, для чисел — нет. Булев столбец is_active уже содержит true или false: WHERE is_active выбирает активные товары.

Не используйте имя вычисленного столбца из SELECT в WHERE того же уровня. PostgreSQL логически отбирает строки раньше, чем формирует результат SELECT. Повторите выражение или используйте подзапрос, о котором речь пойдёт дальше.

Попробуйте: найдите активные товары дешевле 2000. Затем замените AND на OR и объясните, какие дополнительные товары теперь допустимы.

6. ORDER BY и LIMIT: устойчивый результат

Таблица не обещает определённого порядка строк. Даже если вчера записи пришли по номеру, завтра план выполнения может измениться. ORDER BY делает порядок частью запроса. ASC — по возрастанию, DESC — по убыванию. Если направление не указано, применяется ASC.

SELECT product_id, product_name, price
FROM products
ORDER BY price DESC, product_id ASC
LIMIT 5;

Получаются пять самых дорогих товаров. Если цены одинаковые, второе условие сортировки размещает товары по идентификатору. Такой дополнительный ключ делает выборку однозначной. Без него на границе LIMIT разные выполнения могут выбрать разные товары с одинаковой ценой.

ORDER BY price, product_id и ORDER BY product_id, price — разные порядки: сначала сравнивается первый столбец, второй нужен только при равенстве первого. LIMIT ограничивает число строк результата, но не означает, что база прочитала только столько же строк из таблицы.

OFFSET пропускает указанное число строк после сортировки. LIMIT 5 OFFSET 5 показывает следующую пятёрку. При изменении данных между запросами страницы могут сдвигаться; для первых упражнений достаточно понимать принцип.

Попробуйте: получите пять самых дешёвых товаров и следующую пятёрку. Сохраните одинаковый ORDER BY в обоих запросах.

7. NULL: неизвестное значение

NULL означает отсутствие известного значения. Это не ноль, не пустая строка и не слово 'NULL'. Например, customers.phone может быть неизвестен, а employees.fired_at равен NULL у работающего сотрудника. Значение NULL нужно интерпретировать по смыслу конкретного поля.

Обычное сравнение с NULL даёт неизвестный результат, даже NULL = NULL. WHERE пропускает только true, поэтому phone = NULL не найдёт покупателей без телефона. Для этого существует IS NULL; противоположная проверка — IS NOT NULL.

SELECT customer_id, full_name,
       COALESCE(phone, 'не указан') AS contact_phone
FROM customers
WHERE phone IS NULL
ORDER BY customer_id
LIMIT 5;

COALESCE возвращает первый аргумент, который не равен NULL. Здесь он меняет только отображение результата. Телефоны в таблице остаются прежними. Для чисел можно написать COALESCE(bonus_pct, 0), если по смыслу неизвестный бонус действительно нужно считать нулевым.

Условие phone <> '123' тоже исключает строки с NULL: неизвестно, отличается ли неизвестный телефон от '123'. Если нужны и отсутствующие значения, напишите phone <> '123' OR phone IS NULL. Позже можно изучить IS DISTINCT FROM.

Попробуйте: найдите покупателей с известной датой рождения. Используйте IS NOT NULL, а не сравнение с текстом 'NULL'.

8. Текст, даты и CASE

LIKE сравнивает текст с шаблоном. Символ % обозначает любое количество символов, включая ноль, а _ — ровно один символ. Шаблон 'Ан%' подходит строкам, начинающимся с «Ан». ILIKE в PostgreSQL выполняет похожую проверку без учёта регистра в соответствии с настройками сравнения текста.

SELECT customer_id, full_name
FROM customers
WHERE full_name ILIKE '%ан%'
ORDER BY customer_id
LIMIT 5;

SELECT order_id, created_at, status
FROM orders
WHERE created_at >= TIMESTAMPTZ '2025-01-01 00:00:00+00'
  AND created_at <  TIMESTAMPTZ '2025-02-01 00:00:00+00'
ORDER BY created_at, order_id
LIMIT 5;

Второй запрос выбирает январь в часовом поясе UTC. Верхняя граница исключена: это захватывает весь последний день месяца, включая доли секунды. BETWEEN с последней датой месяца легко пропускает время после полуночи. Тип date хранит день, timestamptz — момент времени, отображаемый с учётом часового пояса сессии.

CASE формирует значение по условиям: CASE WHEN price < 1000 THEN 'недорогой' ELSE 'остальные' END. PostgreSQL проверяет WHEN по порядку и берёт первую подходящую ветку. CASE не фильтрует строки — он вычисляет значение. Для фильтрации нужен WHERE.

Попробуйте: добавьте к выборке товаров столбец price_group с двумя категориями цен. Используйте фиксированные даты из данных курса: текущая дата компьютера уже отличается от учебного периода.

9. Агрегаты и GROUP BY: превращаем строки в отчёт

COUNT, SUM, AVG, MIN и MAX сворачивают несколько значений в одно: количество, сумму, среднее, минимум и максимум. Без GROUP BY все подходящие строки образуют одну общую группу. SELECT count(*) FROM orders отвечает на вопрос «сколько всего заказов», а не выводит каждый заказ.

COUNT(*) считает строки. COUNT(phone) считает только строки с известным phone. SUM и AVG также пропускают NULL. На пустом наборе COUNT возвращает 0, а SUM — NULL: при необходимости используйте COALESCE, осмысленно выбирая замену.

SELECT status, count(*) AS order_count
FROM orders
GROUP BY status
ORDER BY order_count DESC, status;

SELECT order_id, sum(qty * unit_price) AS amount_before_discount
FROM order_items
GROUP BY order_id
HAVING sum(qty * unit_price) > 10000
ORDER BY amount_before_discount DESC, order_id
LIMIT 5;

GROUP BY status создаёт отдельную группу для каждого статуса. В SELECT можно вывести признак группы и агрегаты. Нельзя добавить произвольный order_id: у группы много заказов, и неясно, какой из них показать. Для первых запросов придерживайтесь правила: каждый обычный столбец SELECT должен присутствовать в GROUP BY.

WHERE отбрасывает отдельные строки до группировки, HAVING — готовые группы после вычисления агрегатов. Во втором примере считаем стоимость позиций без скидки. Для учёта discount_pct выражение будет qty * unit_price * (1 - discount_pct / 100).

Попробуйте: посчитайте заказы по channel, затем оставьте только каналы с количеством заказов больше 100. Для второго шага нужен HAVING.

10. JOIN: как собрать данные из двух таблиц

У заказа есть customer_id, а имя покупателя лежит в customers. JOIN позволяет сопоставить строки. Условие ON объясняет, какие строки считать связанными. Оно обычно сравнивает внешний ключ одной таблицы с первичным ключом другой.

SELECT o.order_id, c.full_name, o.status
FROM orders AS o
JOIN customers AS c ON c.customer_id = o.customer_id
ORDER BY o.order_id
LIMIT 5;

o и c — короткие имена таблиц, действующие внутри этого запроса. Запись o.customer_id явно указывает источник столбца. Прочитайте запрос: «Для каждого заказа найти покупателя с совпадающим идентификатором и вывести номер заказа, имя и статус». JOIN без уточнения означает INNER JOIN: в результате остаются только совпавшие пары.

LEFT JOIN сохраняет все строки левой таблицы. Если справа совпадения нет, столбцы правой стороны заполняются NULL. Чтобы показать покупателей без заказов, начните с customers, присоедините orders через LEFT JOIN и отберите WHERE o.order_id IS NULL.

SELECT c.customer_id, c.full_name, count(o.order_id) AS order_count
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.customer_id
GROUP BY c.customer_id, c.full_name
ORDER BY c.customer_id
LIMIT 5;

Здесь COUNT(o.order_id), а не COUNT(*): у покупателя без заказов LEFT JOIN всё равно создаёт одну строку с NULL справа. COUNT(*) посчитал бы её как один заказ.

Частая ошибка: после LEFT JOIN написать WHERE o.status = 'paid' и потерять покупателей без оплаченных заказов. Для сохранения всех покупателей добавьте условие статуса в ON: ON o.customer_id = c.customer_id AND o.status = 'paid'.

Попробуйте: измените последний пример так, чтобы он считал только оплаченные заказы, сохраняя покупателей с нулевым результатом.

11. Почему после JOIN размножаются строки

JOIN возвращает каждую подходящую пару. Если у одного заказа три позиции, после соединения orders с order_items этот заказ встретится три раза. Это ожидаемое поведение связи «один ко многим», а не ошибка PostgreSQL.

Особенно внимательно соединяйте две независимые таблицы деталей. Если у заказа две позиции и три платежа, соединение обеих таблиц по order_id может создать шесть строк. Сумма платежей повторится по каждой позиции, а сумма позиций — по каждому платежу. DISTINCT поверх готового отчёта не исправляет ошибочную арифметику.

SELECT o.order_id, count(*) AS item_rows
FROM orders AS o
JOIN order_items AS i ON i.order_id = o.order_id
GROUP BY o.order_id
ORDER BY o.order_id
LIMIT 5;

Перед написанием отчёта проговорите, чему должна соответствовать одна строка результата: покупателю, заказу или позиции заказа. Затем проверяйте, какие связи увеличивают число строк. Для отчёта по заказам можно сначала отдельно сгруппировать позиции и платежи до одной строки на заказ, затем соединить полученные итоги.

Попробуйте: сравните COUNT(*) из orders и из соединения orders с order_items. Объясните разницу, прежде чем пытаться её устранить.

12. Подзапросы и CTE: решаем задачу по шагам

Подзапрос — запрос внутри другого запроса. Например, сначала вычислить среднюю цену, затем выбрать товары дороже этого значения. В этой ситуации внутренний запрос должен вернуть ровно одно значение, с которым можно сравнить price.

SELECT product_id, product_name, price
FROM products
WHERE price > (SELECT avg(price) FROM products)
ORDER BY price DESC, product_id
LIMIT 5;

CTE, или WITH, даёт промежуточному результату имя. Он существует в пределах одного SQL-оператора. Это способ разделить сложную мысль на понятные части; WITH сам по себе не сохраняет таблицу в базе и не гарантирует ускорения запроса.

WITH order_totals AS (
    SELECT order_id,
           sum(qty * unit_price * (1 - discount_pct / 100)) AS amount
    FROM order_items
    GROUP BY order_id
)
SELECT o.order_id, o.status, t.amount
FROM orders AS o
JOIN order_totals AS t ON t.order_id = o.order_id
ORDER BY t.amount DESC, o.order_id
LIMIT 5;

Сначала мысленно выполните часть внутри скобок: получается по одной строке на заказ. Затем внешний SELECT добавляет статус заказа. Запятая после закрывающей скобки нужна только при объявлении следующего CTE, а не перед основным SELECT.

EXISTS отвечает на другой вопрос: «Есть ли хотя бы одна подходящая строка?» Он удобен, чтобы найти покупателей с заказами без размножения покупателей по количеству заказов. NOT EXISTS ищет отсутствие совпадений и часто понятнее, чем NOT IN, если во внутреннем наборе возможны NULL.

Попробуйте: выполните сначала только SELECT внутри CTE. Убедитесь, что order_id там не повторяется, и лишь затем запускайте весь пример.

13. Оконные функции: итог рядом с каждой строкой

GROUP BY сворачивает строки. Оконная функция считает по связанным строкам, сохраняя каждую из них. Например, можно показать каждый заказ покупателя и одновременно пронумеровать его заказы от первого к последнему.

SELECT customer_id, order_id, created_at,
       row_number() OVER (
           PARTITION BY customer_id
           ORDER BY created_at, order_id
       ) AS order_number
FROM orders
ORDER BY customer_id, created_at, order_id
LIMIT 15;

OVER обозначает окно вычисления. PARTITION BY customer_id делит строки на наборы по покупателю, но не сворачивает их. ORDER BY внутри OVER задаёт последовательность нумерации. Внешний ORDER BY независимо определяет, как показать результат на экране. Эти две сортировки выполняют разные задачи.

ROW_NUMBER всегда выдаёт разные номера строк. RANK допускает одинаковое место при равных значениях сортировки и пропускает следующие места. LAG позволяет обратиться к значению предыдущей строки окна. Осваивайте их после уверенного понимания обычной сортировки и группировки.

Нельзя фильтровать результат оконной функции через WHERE того же уровня. Поместите такой SELECT в CTE, а затем во внешнем запросе отберите WHERE order_number = 1. Так получится первый заказ каждого покупателя.

Попробуйте: сделайте описанный CTE и получите первые заказы покупателей. Проверьте, что каждый customer_id встречается один раз.

14. Создание и изменение данных: безопасная практика

До этого примеры только читали данные. CREATE TABLE создаёт таблицу, INSERT добавляет строки, UPDATE изменяет существующие, DELETE удаляет их. Для знакомства используем отдельную таблицу sandbox.notes, чтобы упражнения не меняли исходные товары и заказы.

Транзакция объединяет действия в одно целое. BEGIN начинает её, COMMIT сохраняет изменения, ROLLBACK отменяет изменения текущей транзакции. В примере ниже специально используется ROLLBACK: созданная таблица и все изменения исчезнут после завершения.

BEGIN;

CREATE TABLE sandbox.notes (
    note_id integer PRIMARY KEY,
    note_text text NOT NULL
);

INSERT INTO sandbox.notes (note_id, note_text)
VALUES (1, 'Мой первый SQL'), (2, 'Изучаю PostgreSQL');

UPDATE sandbox.notes
SET note_text = 'Умею изменять строки'
WHERE note_id = 1;

SELECT note_id, note_text
FROM sandbox.notes
ORDER BY note_id;

DELETE FROM sandbox.notes WHERE note_id = 2;

ROLLBACK;

Выполняйте этот блок целиком в одном окне Query Tool. Результат SELECT показывает состояние до DELETE и ROLLBACK. После ROLLBACK таблицы уже не будет. Если таблица sandbox.notes ранее создана вами и сохранена, выберите другое имя для всех команд блока.

UPDATE или DELETE без WHERE затрагивает все строки таблицы. Привычка новичка: сначала выполнить SELECT с нужным WHERE, проверить найденные строки и только потом писать изменение. Первичный ключ запрещает повторяющиеся идентификаторы, NOT NULL требует значение. Ограничения помогают базе отклонять некорректные записи.

Попробуйте: измените текст первой заметки в примере и повторите блок целиком. Не заменяйте ROLLBACK на COMMIT, пока не понимаете, какие изменения сохранятся.

15. Как самостоятельно составлять запрос

Возьмём задачу: «Для каждого покупателя посчитать оплаченные заказы и показать пять покупателей с наибольшим количеством». Сначала определите единицу результата: одна строка — один покупатель. Нужны customers для имени и orders для заказов. Связь — customer_id.

Решите, нужны ли покупатели без оплаченных заказов. В нашем варианте нужны, поэтому используем LEFT JOIN и условие статуса в ON. Затем группируем по покупателю, считаем известные идентификаторы заказов и сортируем итог.

SELECT c.customer_id,
       c.full_name,
       count(o.order_id) AS paid_orders
FROM customers AS c
LEFT JOIN orders AS o
  ON o.customer_id = c.customer_id
 AND o.status = 'paid'
GROUP BY c.customer_id, c.full_name
ORDER BY paid_orders DESC, c.customer_id
LIMIT 5;

Здесь 'paid' означает именно текущее значение статуса заказа. Это не все когда-либо оплаченные заказы: часть из них уже может иметь статус shipped или delivered. Для реального финансового отчёта нужно отдельно определить бизнес-правило, например учитывать успешные платежи из payments. Верный SQL начинается с точного смысла задачи.

Логический порядок чтения запроса: FROM и JOIN находят источники; WHERE отбирает строки; GROUP BY формирует группы; HAVING отбирает группы; SELECT формирует результат; ORDER BY сортирует; LIMIT ограничивает вывод. Это модель для понимания, а не обещание физического порядка действий PostgreSQL.

Проверяйте ответ на маленьком примере. Возьмите одного покупателя, посмотрите его заказы отдельно и пересчитайте подходящие строки вручную. Убедитесь, что JOIN не умножил записи, NULL обработан осмысленно, а сортировка определена явно.

Попробуйте: замените статус paid на delivered. Затем уберите LIMIT и проверьте одного покупателя отдельным запросом к orders.

16. Маршрут обучения и словарь

На первом занятии пройдите главы 1–4 и получите несколько простых выборок. На втором — WHERE, сортировку и NULL. На третьем — агрегаты и GROUP BY. Затем переходите к JOIN: это часто самый трудный первый шаг, поэтому возвращайтесь к таблицам и ключам из главы 3. Подзапросы, CTE и окна изучайте после соединений. Изменение данных практикуйте отдельно в sandbox.

После главы выбирайте соответствующую тему тренажёра и решайте сначала задания начального уровня. Если застряли, сформулируйте, что должно означать одно поле и одна строка результата. Прочитайте подсказку, попробуйте ещё раз и лишь затем смотрите решение. Перепишите решение без подсматривания и объясните каждую строку.

Вы готовы к самостоятельной практике, когда можете без подсказки выбрать столбцы, отобрать строки, объяснить NULL, посчитать группы и соединить заказ с покупателем по ключу. Продвинутые темы тренажёра не обязательны для первых занятий.