Примеры SQL-запросов: 26 решений с разбором для аналитика
daily_revenue содержит date и revenue. Как это сделать?Содержание:
Зачем нужны готовые примеры SQL-запросов
На реальной работе 80% запросов аналитика — это вариации одних и тех же паттернов. «Посчитай DAU», «дай топ-10 клиентов», «выручка по категориям за квартал» — каждая такая задача решается шаблоном, который проще держать под рукой, чем выводить заново. Тот же набор паттернов спрашивают и на собеседовании: SELECT с фильтром, GROUP BY, LEFT JOIN, оконные функции и пара CTE закрывают большинство live-coding секций.
Ниже — 26 примеров SQL-запросов, разбитых по типам задач. Для каждого: что нужно посчитать словами, рабочий запрос и объяснение, почему он написан именно так и где легко споткнуться. Все запросы — под PostgreSQL, самую частую СУБД в продуктовых командах и на собеседованиях. Чтобы паттерны осели в пальцах, а не остались закладкой, прорешивайте их в SQL-тренажёре по 10 минут в день.
Выборка и фильтрация
1. Уникальные пользователи за период
Задача: сколько разных людей сделали хоть какое-то событие в марте.
SELECT COUNT(DISTINCT user_id) AS unique_users
FROM events
WHERE event_date BETWEEN '2026-03-01' AND '2026-03-31';COUNT(DISTINCT user_id) считает уникальные значения, а не строки: один пользователь с десятью событиями попадёт в счётчик один раз. BETWEEN включает обе границы, поэтому 31 марта тоже в выборке.
2. Заказы с несколькими условиями за последние 30 дней
Задача: выгрузить оплаченные заказы дороже 1000 рублей за последний месяц.
SELECT order_id, user_id, amount, created_at
FROM orders
WHERE status = 'completed'
AND amount > 1000
AND created_at >= CURRENT_DATE - INTERVAL '30 days';CURRENT_DATE - INTERVAL '30 days' считает границу относительно сегодня — запрос не надо править каждый день. Перечисляйте колонки явно вместо SELECT *: так запрос читается и не ломается при изменении схемы таблицы.
3. Топ-10 клиентов по сумме покупок
Задача: кто принёс больше всего денег.
SELECT user_id, SUM(amount) AS total_spent
FROM orders
GROUP BY user_id
ORDER BY total_spent DESC
LIMIT 10;Сначала суммируем по пользователю, потом сортируем по убыванию и обрезаем десятью строками. ORDER BY всегда идёт перед LIMIT, иначе вы обрежете случайные строки до сортировки.
Агрегации и группировки
4. Распределение по категориям с долей в процентах
Задача: сколько товаров в каждой категории и какой процент от всего каталога.
SELECT category,
COUNT(*) AS cnt,
ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) AS pct
FROM products
GROUP BY category
ORDER BY cnt DESC;SUM(COUNT(*)) OVER () — это агрегат поверх агрегата: оконная функция складывает все групповые COUNT(*) и даёт общий итог в каждой строке. Множитель 100.0 обязателен: без него COUNT / COUNT — это деление целых, и PostgreSQL усечёт результат до нуля.
5. Средний чек по месяцам
Задача: динамика среднего чека и числа заказов помесячно.
SELECT DATE_TRUNC('month', created_at) AS month,
COUNT(*) AS orders,
ROUND(AVG(amount), 2) AS avg_check
FROM orders
GROUP BY 1
ORDER BY 1;DATE_TRUNC('month', ...) сбрасывает дату к первому числу месяца, поэтому все заказы одного месяца сворачиваются в одну строку. GROUP BY 1 группирует по первому выражению из SELECT — короче, чем повторять DATE_TRUNC целиком.
6. Пользователи с пятью и более заказами
Задача: отобрать активных клиентов по числу покупок.
SELECT user_id, COUNT(*) AS order_count
FROM orders
GROUP BY user_id
HAVING COUNT(*) >= 5;WHERE фильтрует строки до группировки, а HAVING — уже посчитанные группы. Условие на агрегат (COUNT(*) >= 5) можно поставить только в HAVING.
7. Конверсия воронки в один запрос
Задача: посчитать просмотры, корзины, покупки и конверсию из просмотра в покупку.
SELECT
COUNT(DISTINCT user_id) FILTER (WHERE event = 'view') AS views,
COUNT(DISTINCT user_id) FILTER (WHERE event = 'cart') AS carts,
COUNT(DISTINCT user_id) FILTER (WHERE event = 'purchase') AS purchases,
ROUND(
100.0 * COUNT(DISTINCT user_id) FILTER (WHERE event = 'purchase')
/ NULLIF(COUNT(DISTINCT user_id) FILTER (WHERE event = 'view'), 0),
1
) AS cr_pct
FROM events;FILTER (WHERE ...) считает каждый шаг воронки в отдельной колонке за один проход по таблице. NULLIF(..., 0) в знаменателе страхует от деления на ноль: если просмотров не было, вернётся NULL, а не ошибка. Подробнее: воронка конверсии в SQL.
8. Сегментация клиентов через CASE WHEN
Задача: разбить пользователей на VIP, постоянных и новых по сумме покупок.
SELECT
CASE
WHEN total >= 100000 THEN 'VIP'
WHEN total >= 10000 THEN 'Regular'
ELSE 'New'
END AS segment,
COUNT(*) AS users
FROM (
SELECT user_id, SUM(amount) AS total
FROM orders
GROUP BY user_id
) t
GROUP BY 1
ORDER BY users DESC;Сначала во вложенном запросе считаем сумму по каждому пользователю, потом CASE WHEN раскладывает их по корзинам. Условия проверяются сверху вниз: первый сработавший WHEN выигрывает, поэтому более строгий порог (100000) идёт раньше.
JOIN: соединяем таблицы
9. Подтянуть данные из справочника
Задача: к каждому заказу добавить имя и город клиента.
SELECT o.order_id, o.amount, u.name, u.city
FROM orders o
JOIN users u ON o.user_id = u.user_id;Обычный JOIN (он же INNER JOIN) оставляет только те заказы, у которых нашёлся пользователь. Если у какого-то заказа user_id не совпадёт с таблицей users, строка выпадет из результата.
10. Пользователи без единого заказа
Задача: найти зарегистрированных, но ничего не купивших.
SELECT u.user_id, u.name
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id
WHERE o.order_id IS NULL;LEFT JOIN сохраняет всех пользователей, а у тех, кто ничего не заказывал, колонки из orders будут NULL. Условие o.order_id IS NULL оставляет ровно этих «пустых». Подробнее: JOIN в SQL — шпаргалка.
11. Выручка по категориям через три таблицы
Задача: посчитать выручку по категориям товаров за год.
SELECT p.category,
SUM(oi.quantity * oi.price) AS revenue
FROM order_items oi
JOIN orders o ON oi.order_id = o.order_id
JOIN products p ON oi.product_id = p.product_id
WHERE o.created_at >= '2026-01-01'
GROUP BY p.category
ORDER BY revenue DESC;Цепочка JOIN связывает позиции заказа со справочником товаров и с самим заказом, чтобы добраться до даты и категории. Выручку считаем на уровне позиций (quantity * price) и только потом суммируем — иначе при склейке таблиц легко задвоить суммы.
Оконные функции
12. Нумерация заказов внутри пользователя
Задача: пронумеровать заказы каждого клиента по времени — первый, второй, третий.
SELECT user_id, order_id, created_at,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) AS order_num
FROM orders;PARTITION BY user_id сбрасывает счётчик на каждом новом пользователе, а ORDER BY created_at задаёт порядок нумерации. В отличие от GROUP BY, оконная функция не схлопывает строки — все заказы остаются на месте, просто к каждому добавляется номер.
13. Running total — нарастающий итог
Задача: накопленная выручка по дням.
SELECT created_at::DATE AS day,
SUM(amount) AS daily,
SUM(SUM(amount)) OVER (ORDER BY created_at::DATE) AS running_total
FROM orders
GROUP BY created_at::DATE
ORDER BY day;Внутренний SUM(amount) даёт дневную выручку, а внешний SUM(...) OVER (ORDER BY ...) накапливает её по нарастающей. Окно по умолчанию суммирует все строки от начала до текущей даты включительно — это и есть running total.
14. Топ-N в каждой группе
Задача: три самых продаваемых товара в каждой категории.
WITH ranked AS (
SELECT product_id, category, sales,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY sales DESC) AS rn
FROM products
)
SELECT product_id, category, sales
FROM ranked
WHERE rn <= 3;Фильтровать по оконной функции прямо в WHERE нельзя — она вычисляется уже после WHERE. Поэтому ранг считаем в CTE, а отбор rn <= 3 делаем во внешнем запросе. Это самый частый паттерн «топ-N на группу».
15. Сравнение с прошлым месяцем через LAG
Задача: насколько выручка изменилась по сравнению с предыдущим месяцем.
SELECT month, revenue,
LAG(revenue) OVER (ORDER BY month) AS prev_month,
revenue - LAG(revenue) OVER (ORDER BY month) AS diff
FROM monthly_revenue;LAG(revenue) берёт значение из предыдущей строки по заданному порядку. В первой строке предыдущего значения нет, поэтому prev_month и diff там будут NULL — это нормально. Подробнее: оконные функции SQL — шпаргалка.
16. Доля сотрудника в фонде оплаты отдела
Задача: какой процент от зарплатного фонда отдела получает каждый сотрудник.
SELECT department, employee, salary,
ROUND(100.0 * salary / SUM(salary) OVER (PARTITION BY department), 1) AS pct_of_dept
FROM employees;SUM(salary) OVER (PARTITION BY department) даёт сумму по отделу в каждой строке, не схлопывая сотрудников. 100.0 снова обязателен, чтобы деление шло в числах с плавающей точкой, а не целочисленно.
Подзапросы и CTE
17. Сотрудники с зарплатой выше средней по отделу
Задача: найти тех, кто получает больше среднего в своём отделе.
SELECT e.employee, e.department, e.salary
FROM employees e
WHERE e.salary > (
SELECT AVG(salary)
FROM employees
WHERE department = e.department
);Это коррелированный подзапрос: для каждой строки внешнего запроса он считает среднюю зарплату именно её отдела (department = e.department). На больших таблицах его часто переписывают на оконную функцию AVG(salary) OVER (PARTITION BY department) ради скорости, но для понимания логики такой вариант нагляднее.
18. Многошаговый расчёт через CTE
Задача: топ-100 клиентов с их рангом по сумме покупок.
WITH user_totals AS (
SELECT user_id, SUM(amount) AS total
FROM orders
GROUP BY user_id
),
ranked AS (
SELECT user_id, total,
RANK() OVER (ORDER BY total DESC) AS position
FROM user_totals
)
SELECT user_id, total, position
FROM ranked
WHERE position <= 100;CTE (WITH ... AS) разбивают вычисление на читаемые шаги: сначала суммы по клиентам, затем ранжирование, затем отбор. Это удобнее вложенных подзапросов и не мешает оптимизатору. Подробнее: CTE в SQL (WITH).
19. Есть ли у пользователя хоть один заказ — EXISTS
Задача: оставить только пользователей, у которых есть заказы.
SELECT u.user_id, u.name
FROM users u
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.user_id = u.user_id
);EXISTS останавливается на первой найденной строке, поэтому работает быстро и не зависит от количества заказов. Важнее другое: его зеркальный NOT EXISTS безопасен с NULL, в отличие от NOT IN. Если в подзапросе для NOT IN встретится хоть один NULL, весь результат станет пустым — это классическая ловушка.
Работа с датами
20. DAU за последние 30 дней
Задача: число уникальных активных пользователей по дням.
SELECT activity_date,
COUNT(DISTINCT user_id) AS dau
FROM user_activity
WHERE activity_date >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY activity_date
ORDER BY activity_date;COUNT(DISTINCT user_id) в разрезе дня и даёт Daily Active Users. Фильтр по относительной дате (CURRENT_DATE - INTERVAL '30 days') держит окно скользящим — отчёт не надо переписывать каждое утро.
21. Группировка по неделям
Задача: количество заказов понедельно.
SELECT DATE_TRUNC('week', created_at) AS week,
COUNT(*) AS orders
FROM orders
GROUP BY 1
ORDER BY 1;DATE_TRUNC('week', ...) в PostgreSQL отсчитывает неделю от понедельника. Все заказы недели сворачиваются в одну строку с датой её начала.
22. Заполнить пропуски в датах
Задача: график заказов по дням без «дыр», даже если в какой-то день заказов не было.
SELECT d::DATE AS day,
COALESCE(o.cnt, 0) AS orders
FROM generate_series('2026-01-01', '2026-03-31', INTERVAL '1 day') d
LEFT JOIN (
SELECT created_at::DATE AS day, COUNT(*) AS cnt
FROM orders
GROUP BY created_at::DATE
) o ON d::DATE = o.day
ORDER BY day;generate_series создаёт непрерывный ряд дат, а LEFT JOIN подтягивает к нему фактические заказы. COALESCE(o.cnt, 0) подставляет ноль в пустые дни, чтобы на графике не было разрывов.
23. Сколько полных месяцев между событиями
Задача: на сколько месяцев растянулась активность клиента — от первого заказа до последнего.
SELECT user_id,
(EXTRACT(YEAR FROM AGE(last_order, first_order)) * 12
+ EXTRACT(MONTH FROM AGE(last_order, first_order)))::INT AS months_active
FROM (
SELECT user_id,
MIN(created_at) AS first_order,
MAX(created_at) AS last_order
FROM orders
GROUP BY user_id
) t;EXTRACT(MONTH FROM AGE(...)) сам по себе врёт: он возвращает только остаток от 0 до 11, отбрасывая годы. Чтобы получить честное число месяцев, годы из AGE умножают на 12 и прибавляют месяцы.
Дедупликация и поиск дублей
24. Последняя сессия каждого пользователя
Задача: оставить по одной — самой свежей — сессии на пользователя.
SELECT DISTINCT ON (user_id) user_id, session_id, session_start
FROM user_sessions
ORDER BY user_id, session_start DESC;DISTINCT ON (user_id) — расширение PostgreSQL: оставляет первую строку для каждого user_id. Какая именно строка «первая», решает ORDER BY: сначала по user_id, затем по session_start DESC, то есть берётся самая поздняя сессия.
25. Найти дубли по email
Задача: показать почты, которые встречаются в таблице больше одного раза.
SELECT email, COUNT(*) AS cnt
FROM users
GROUP BY email
HAVING COUNT(*) > 1;Группируем по email и оставляем только те группы, где строк больше одной. Это первый шаг любой чистки: сначала понять масштаб проблемы, потом решать, какие строки удалять. Подробнее: как найти дубликаты в SQL.
26. Оставить из дублей одну строку
Задача: при чистке оставить самую раннюю запись на каждый email, остальные пометить на удаление.
WITH numbered AS (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY email ORDER BY created_at) AS rn
FROM users
)
SELECT *
FROM numbered
WHERE rn = 1;ROW_NUMBER() нумерует дубли внутри каждого email по дате создания. Строки с rn = 1 — те, что оставляем; всё с rn > 1 — кандидаты на удаление. Окно снова прячем в CTE, потому что фильтровать по rn в WHERE напрямую нельзя.
Частые ошибки
Деление целых чисел — ошибка номер один. В PostgreSQL COUNT / COUNT или 5 / 20 возвращают целое: дробная часть отбрасывается, и процент превращается в ноль. Лечится множителем 100.0 в числителе или приведением ::NUMERIC — тогда деление идёт в дробных числах.
Деление на ноль роняет весь запрос. Если знаменатель может оказаться нулём (например, ноль просмотров в воронке), оборачивайте его в NULLIF(знаменатель, 0): вместо ошибки получите NULL, который дальше легко обработать через COALESCE.
Фильтр по оконной функции в WHERE не работает. Оконные функции вычисляются после WHERE, поэтому WHERE row_number() = 1 выдаст ошибку. Считайте окно в CTE или подзапросе, а отбор делайте на уровень выше — этот приём встречается в задачах на топ-N и дедупликацию.
NOT IN с подзапросом, где есть NULL, тихо возвращает пусто. Один NULL в списке — и условие никогда не будет истинным. Для исключающих фильтров используйте NOT EXISTS или LEFT JOIN ... WHERE ... IS NULL: они ведут себя предсказуемо.
EXTRACT(MONTH FROM AGE(...)) отдаёт только остаток месяцев без учёта лет. Для разницы в полных месяцах складывайте годы, умноженные на 12, с месяцами. А COUNT(DISTINCT ...) нельзя использовать как оконную функцию — PostgreSQL такого не умеет, считайте через подзапрос или предварительную агрегацию.
Двойной счёт при JOIN нескольких таблиц «один-ко-многим» — тихий, но опасный баг. Если соединить заказы и с позициями, и с платежами разом, суммы перемножатся. Агрегируйте каждую сторону в своём CTE, а потом соединяйте уже свёрнутые результаты.
Связанные темы
- Оконные функции SQL: шпаргалка
- JOIN в SQL: шпаргалка
- CTE в SQL (WITH)
- Работа с датами в SQL
- 50 SQL-задач для собеседования
FAQ
Какие SQL-запросы аналитик пишет чаще всего?
GROUP BY с агрегациями — это примерно 80% рабочих задач. Дальше идут LEFT JOIN для подтягивания справочников, оконные функции для рангов и нарастающих итогов и DATE_TRUNC для группировки по периодам. Эти четыре паттерна закрывают большую часть ежедневной работы.
Чем рабочий SQL отличается от учебного?
Рабочие запросы длиннее (30–100 строк), используют CTE для читаемости, аккуратно обрабатывают NULL и крайние случаи и почти всегда соединяют несколько таблиц. На собеседовании задачи короче, но проверяют ровно те же паттерны — поэтому тренироваться на компактных примерах эффективно.
На какой СУБД учить примеры запросов?
PostgreSQL. Его используют в большинстве продуктовых компаний, а синтаксис принимают почти на любом собеседовании. Если в команде ClickHouse или BigQuery, основы те же — отличается в основном работа с датами и оконными функциями, базовые SELECT/JOIN/GROUP BY переносятся один в один.
Почему деление в SQL возвращает ноль?
Потому что оба операнда — целые числа, и PostgreSQL делит их нацело, отбрасывая дробную часть. Добавьте 100.0 в числитель или приведите одну из сторон к ::NUMERIC, и деление пойдёт в дробных числах.
Нужно ли знать все эти запросы наизусть?
Наизусть — нет, важнее понимать логику. Базовые паттерны (выборка, GROUP BY, JOIN, простые окна) должны идти на автомате, потому что их спрашивают на каждом собеседовании. Продвинутые (многошаговые CTE, дедупликация, расчёты по датам) достаточно уметь быстро собрать, подглядывая в свою шпаргалку.
Как быстрее всего набить руку на SQL?
Решать по несколько задач в день с разбором ошибок, а не просто читать примеры. Берите паттерн, переписывайте под свои таблицы, ломайте и чините. Регулярная практика по 10–15 минут даёт больше, чем разовый марафон на выходных.