Без оконных функций в аналитике никуда. На собесе они почти всегда — если не дали задачу на ROW_NUMBER, то на LAG или running total. В работе — та же история: когорты, retention, дедупликация, топ-N, скользящие метрики — всё это считается через оконные.
Дальше разберём по порядку: восемь функций, PARTITION BY, ORDER BY, frame clauses, бенчмарки против GROUP BY, типичные грабли и пять задач с собесов. Параллельно можно тренироваться в SQL-тренажёре — там есть отдельная категория на оконные функции.
Что такое оконная функция
Обычный SUM или AVG схлопывает строки: GROUP BY city превращает 10 000 заказов в 50 городов, и детали теряются.
Оконная функция считает тот же агрегат, но строки остаются. К каждой просто прибавляется новое поле — значение по её «окну». Окно — это группа строк, связанных с текущей: «все заказы этого пользователя», «последние 7 дней», «всё что до сегодня».
GROUP BY против оконной — на пальцах
Простая таблица заказов:
| user_id | order_date | amount |
|---|---|---|
| 1 | 2026-01-05 | 500 |
| 1 | 2026-01-12 | 300 |
| 1 | 2026-02-01 | 700 |
| 2 | 2026-01-10 | 1200 |
| 2 | 2026-01-20 | 400 |
1. Через GROUP BY:
SELECT
user_id,
SUM(amount) AS total
FROM orders
GROUP BY user_id;
Получаем две строки, заказы схлопнулись:
| user_id | total |
|---|---|
| 1 | 1500 |
| 2 | 1600 |
2. Через оконную функцию:
SELECT
user_id,
order_date,
amount,
SUM(amount) OVER (PARTITION BY user_id) AS user_total
FROM orders;
Все пять строк на месте, просто у каждой добавился user_total — сумма по её пользователю. С цветной подсветкой партиций видно лучше:
| user_id | order_date | amount | user_total |
|---|---|---|---|
| П11 | 2026-01-05 | 500 | 1500 |
| П11 | 2026-01-12 | 300 | 1500 |
| П11 | 2026-02-01 | 700 | 1500 |
| П22 | 2026-01-10 | 1200 | 1600 |
| П22 | 2026-01-20 | 400 | 1600 |
Одним запросом получили и каждый заказ, и сумму по его пользователю. На этом паттерне строятся половина аналитических запросов: доля заказа в месячной выручке, заказы выше среднего по клиенту, первый заказ каждого. Всё в один SELECT, без подзапросов и JOIN.
Синтаксис OVER
Общая форма такая:
функция() OVER (
PARTITION BY <поля> -- на какие группы резать (опционально)
ORDER BY <поля> -- в каком порядке внутри группы (опционально)
<frame_clause> -- какой кусок окна учитывать (опционально)
)
Три части внутри OVER отвечают за разное:
PARTITION BY user_id — своё окно на каждого пользователя. Похоже на GROUP BY, но строки не схлопываются.ROW_NUMBER, LAG, LEAD и накопительной суммы. Без него СУБД сама решает порядок и результат плавает между запусками.ROWS BETWEEN или RANGE BETWEEN. Нужен для скользящих средних, накопительных сумм с лимитом и любых «окон с заданной шириной».Восемь функций, которых хватает на 99% задач
Этот список закроет и собес, и 99% реальных задач. В SQL-тренажёре можно сразу тренироваться после каждого раздела — там похожие задачи с проверкой.
ROW_NUMBER() — сквозная нумерация
Даёт каждой строке уникальный номер внутри окна. Если в ORDER BY есть одинаковые значения — порядок между ними непредсказуем. Чтобы результат был стабильным, добавь второй ключ сортировки, обычно id.
SELECT
user_id,
order_date,
amount,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY order_date
) AS n
FROM orders;
| user_id | order_date | amount | n |
|---|---|---|---|
| П11 | 2026-01-05 | 500 | 1 |
| П11 | 2026-01-12 | 300 | 2 |
| П11 | 2026-02-01 | 700 | 3 |
| П22 | 2026-01-10 | 1200 | 1 |
| П22 | 2026-01-20 | 400 | 2 |
Типовой кейс: взять первую покупку каждого пользователя — основа когортного анализа и построения метрик retention.
WITH numbered AS (
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY order_date
) AS n
FROM orders
)
SELECT *
FROM numbered
WHERE n = 1;
RANK() — ранжирование с пропусками
При одинаковых значениях RANK присваивает одинаковый ранг, но следующий номер пропускает. Если два заказа делят первое место — следующий будет третьим, а не вторым.
DENSE_RANK() — ранжирование без пропусков
Работает как RANK, но без пропусков. Если два заказа делят первое место — следующий будет вторым.
Сравнение всех трёх функций на одном примере — продажи по сумме. Одинаковые ранги подсвечены зелёным, различия (где RANK пропускает номер, а DENSE_RANK нет) — оранжевым.
| amount | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|
| 1200 | 1 | 1 | 1 |
| 1200 | 2 | 1 | 1 |
| 800 | 3 | 3 | 2 |
| 500 | 4 | 4 | 3 |
Обратите внимание на третью строку: при одинаковых amount=1200 в первых двух строках RANK присваивает обеим «1», а следующей строке — сразу «3» (второе место «съедается»). DENSE_RANK даёт тем же двум «1», но следующей — «2», без пропуска.
LAG() — значение предыдущей строки
LAG(column, N, default) берёт значение из строки, которая на N позиций раньше в том же окне. По умолчанию N=1. Для самой первой строки ничего «раньше» нет → NULL, но это можно заменить на что угодно через третий аргумент.
SELECT
user_id,
order_date,
amount,
LAG(amount) OVER (
PARTITION BY user_id
ORDER BY order_date
) AS prev_amount,
amount - LAG(amount) OVER (
PARTITION BY user_id
ORDER BY order_date
) AS diff
FROM orders;
Такой запрос сразу показывает, на сколько заказ отличается от предыдущего. Отсюда легко получить любую динамику: вырос или упал средний чек, какой интервал между покупками, есть ли признаки оттока.
LEAD() — значение следующей строки
Работает зеркально LAG — смотрит не назад, а вперёд. Пригодится когда нужно «сколько дней до следующего события» или сравнить текущую строку с будущей.
-- PostgreSQL / стандартный SQL: разница между датами в днях
SELECT
user_id,
order_date,
LEAD(order_date) OVER (
PARTITION BY user_id
ORDER BY order_date
) AS next_date,
LEAD(order_date) OVER (
PARTITION BY user_id
ORDER BY order_date
) - order_date AS days_to_next
FROM orders;
Для SQLite та же задача решается через julianday():
-- SQLite: julianday возвращает число дней от базовой даты
SELECT
user_id,
order_date,
julianday(LEAD(order_date) OVER (
PARTITION BY user_id
ORDER BY order_date
)) - julianday(order_date) AS days_to_next
FROM orders;
На этом строится половина retention-аналитики: дни между покупками, признаки оттока, средний цикл. Сами формулы — в разделе Метрики.
SUM / AVG / COUNT OVER — агрегат по окну
Любой агрегат (SUM, AVG, COUNT, MIN, MAX) становится оконным, если добавить OVER (...). Агрегат считается по окну, строки остаются на месте.
С ORDER BY внутри OVER получается накопительная сумма — сумма с начала окна до текущей строки.
SELECT
user_id,
order_date,
amount,
SUM(amount) OVER (
PARTITION BY user_id
ORDER BY order_date
) AS running_total
FROM orders;
Визуально видно как накопительная сумма растёт от заказа к заказу — каждый новый заказ прибавляется к предыдущему итогу:
Так считаются LTV по когортам, накопительная выручка с начала месяца и другие метрики, где важна динамика во времени.
FIRST_VALUE() / LAST_VALUE()
Берут значение первой или последней строки окна. Используются когда нужна «якорная» точка: например, сравнить каждый заказ с первым заказом пользователя — видно, растёт ли чек со временем.
SELECT
user_id,
order_date,
amount,
FIRST_VALUE(amount) OVER (
PARTITION BY user_id
ORDER BY order_date
) AS first_order,
amount - FIRST_VALUE(amount) OVER (
PARTITION BY user_id
ORDER BY order_date
) AS diff_vs_first
FROM orders;
LAST_VALUE без явно указанного frame ведёт себя не так, как ожидается. По умолчанию окно ограничено текущей строкой (RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW), то есть «последняя строка» = текущая строка. Чтобы получить настоящую последнюю строку в группе, нужно явно писать ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING. Это один из любимых вопросов на собеседованиях middle+.NTILE(N) — разбиение на N корзин
NTILE(N) режет строки окна на N примерно равных корзин. Квартили, децили, процентильная сегментация — это всё про него.
SELECT
user_id,
total_spent,
NTILE(4) OVER (ORDER BY total_spent DESC) AS spending_quartile
FROM user_totals;
Квартиль 1 — топ 25% по тратам, квартиль 4 — нижние 25%. На этом строится RFM и любая сегментация клиентов по поведению.
| user_id | total_spent | quartile | сегмент |
|---|---|---|---|
| Q17 | 50 000 | 1 | топ-25%: VIP |
| Q12 | 42 000 | 1 | топ-25%: VIP |
| Q25 | 28 000 | 2 | выше среднего |
| Q21 | 22 000 | 2 | выше среднего |
| Q38 | 15 000 | 3 | ниже среднего |
| Q33 | 9 000 | 3 | ниже среднего |
| Q46 | 4 500 | 4 | нижние 25%: риск оттока |
| Q44 | 1 200 | 4 | нижние 25%: риск оттока |
Формулы LTV, ARPU и ретеншна с готовыми SQL-запросами — в каталоге Метрики. Теория про сегментацию в целом — в разделе Теория.
Frame clauses — границы окна
Когда в OVER есть ORDER BY, окно по умолчанию — «от начала партиции до текущей строки». Но часто нужно другое: «последние 7 дней», «весь диапазон партиции», «только будущие строки». Тут в игру входит frame clause.
ROWS или RANGE
ROWS считает по физическим строкам. RANGE — по логическому диапазону значений.
-- ROWS: ровно N физических строк независимо от их значений
AVG(amount) OVER (
ORDER BY order_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
)
-- RANGE: все строки в логическом диапазоне
-- (синтаксис PostgreSQL — поддержка INTERVAL зависит от СУБД)
AVG(amount) OVER (
ORDER BY order_date
RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW
)
ROWS 6 PRECEDING AND CURRENT ROW — ровно 7 записей: шесть предыдущих плюс текущая. RANGE INTERVAL '7 days' PRECEDING — все записи с order_date за последние 7 календарных дней. Их может быть 5, а может 20 — зависит от плотности данных.
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — накопительная сумма от началаROWS BETWEEN 2 PRECEDING AND CURRENT ROW — скользящее окно на 3 строкиROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING — от текущей до концаROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING — вся партицияОранжевым залиты строки, попадающие в окно когда курсор стоит на пятой строке. Когда курсор сдвигается на шестую — окно сдвигается вместе с ним. Это и есть sliding window.
Пример: скользящее среднее выручки за 7 дней
Стандартная задача: сгладить дневную выручку скользящим средним, чтобы убрать недельную сезонность (выходные против будней).
Два шага через CTE. Сначала считаем дневную выручку обычным GROUP BY, потом прогоняем её через оконное AVG с окном в 7 строк:
WITH daily AS (
SELECT
order_date,
SUM(amount) AS daily_revenue
FROM orders
GROUP BY order_date
)
SELECT
order_date,
daily_revenue,
AVG(daily_revenue) OVER (
ORDER BY order_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS rolling_7d_avg
FROM daily
ORDER BY order_date;
Если положить это на график — дневная выручка дёргается вверх-вниз по неделе, а скользящая средняя идёт гладкой линией. Тренд сразу читается.
Плюсы и минусы
Когда оконная функция — лучший инструмент
- Нужны детали и агрегат одновременно. Топ-N заказов на пользователя с сохранением всех полей — без
GROUP BY, в один запрос. - Накопительные суммы.
SUMилиAVGсORDER BY— это накопительный итог в одну строку запроса, без самосоединений. - Доступ к соседним строкам.
LAGиLEAD— разница с предыдущей или следующей строкой. Без оконных функций это делается только через самосоединение. - Перцентили и квартили.
NTILE,PERCENT_RANK,CUME_DISTсчитаются одной функцией — раньше для этого использовалиCROSS JOIN. - Читаемость. Один
OVERвместо трёх подзапросов и двухJOIN.
Когда оконную функцию лучше не использовать
- Агрегат без деталей. Если строки-источники не нужны, обычный
GROUP BYбыстрее и проще. - Простая фильтрация.
WHERE amount > (SELECT AVG(amount) FROM orders)через подзапрос часто быстрее и понятнее, чем оконная функция. - MySQL < 8.0. В старых MySQL оконных функций нет. Вместо них — самосоединения или пользовательские переменные.
- Очень большие партиции. Окно на десятки миллионов строк может работать медленнее
GROUP BYиз-за сортировки внутри окна.
Производительность: сравнение
Замеры на 1 млн заказов (PostgreSQL 15, локальный SSD, прогретый кеш):
| Задача | Классический SQL (GROUP BY / JOIN) | Оконная функция | Разница |
|---|---|---|---|
| Сумма заказов на пользователя | 420 мс | 510 мс | +21% к оконной |
| Первый заказ каждого (ROW_NUMBER) | 1850 мс (self-join) | 640 мс | −65% у оконной |
| Накопительная сумма по дням | 3200 мс (подзапрос) | 280 мс | −91% у оконной |
| Скользящее среднее за 7 дней | 4800 мс (self-join) | 310 мс | −94% у оконной |
| Топ-3 заказа на клиента | 2100 мс (LATERAL) | 580 мс | −72% у оконной |
| Разница с предыдущим заказом | 1600 мс (self-join) | 180 мс | −89% у оконной |
GROUP BY работает естественно и быстрее. Зато они в 5–10× быстрее самосоединений и подзапросов для накопительных сумм, скользящих средних, «первого на группу» и работы с соседними строками. В большинстве аналитических запросов оконная функция обгоняет эквивалент на классическом SQL в разы.Реальные задачи с собеседований
Пять задач с технических собесов аналитика — от простой до продвинутой. Попробуй решить сам, прежде чем смотреть ответ (кнопка «Показать решение» развернёт SQL). Похожие задачи с автопроверкой — в SQL-тренажёре, разборы больших кейсов — в Продуктовых кейсах.
Задача 1 (уровень Junior) — топ-3 товара в каждой категории
Условие: в таблице products с полями category, product, revenue найти три самых прибыльных товара в каждой категории.
Показать решение
SELECT category, product, revenue
FROM (
SELECT
category,
product,
revenue,
ROW_NUMBER() OVER (
PARTITION BY category
ORDER BY revenue DESC
) AS rn
FROM products
) t
WHERE rn <= 3;
Классический паттерн «топ-N в группе»: нумеруем строки в каждой категории по убыванию выручки, затем в обёртке фильтруем rn <= 3. Почему нужна обёртка — потому что WHERE выполняется до оконных функций, про это ниже отдельный раздел.
Задача 2 (уровень Middle) — дни между покупками
Условие: для каждого заказа вывести количество дней, прошедших с предыдущего заказа того же пользователя.
Показать решение (PostgreSQL + SQLite)
-- PostgreSQL: разница дат возвращает число дней
SELECT
user_id,
order_date,
order_date - LAG(order_date) OVER (
PARTITION BY user_id
ORDER BY order_date
) AS days_since_prev
FROM orders;
Для SQLite используется julianday:
-- SQLite: julianday возвращает число дней от точки отсчёта
SELECT
user_id,
order_date,
julianday(order_date) - julianday(
LAG(order_date) OVER (
PARTITION BY user_id
ORDER BY order_date
)
) AS days_since_prev
FROM orders;
На этом запросе строится вся поведенческая аналитика: средний цикл покупки, признаки оттока, сегментация клиентов по частоте заказов.
Задача 3 (уровень Middle) — накопительная доля клиента в выручке
Условие: отсортировать клиентов по убыванию трат и посчитать, какую кумулятивную долю в общей выручке они занимают. Это основа ABC-анализа.
Показать решение
WITH user_totals AS (
SELECT
user_id,
SUM(amount) AS total_spent
FROM orders
GROUP BY user_id
)
SELECT
user_id,
total_spent,
SUM(total_spent) OVER (ORDER BY total_spent DESC) AS cumulative_spent,
ROUND(
100.0 * SUM(total_spent) OVER (ORDER BY total_spent DESC) /
SUM(total_spent) OVER (),
2
) AS cumulative_pct
FROM user_totals
ORDER BY total_spent DESC;
Хитрость в том, что в одном SELECT две оконные функции: первая — накопительная сумма трат, вторая — общая сумма по всем пользователям (без PARTITION BY окно = вся таблица). Делим одну на другую — получаем долю в процентах.
Задача 4 (уровень Middle+) — выручка с начала месяца нарастающим итогом
Условие: по таблице daily_sales (order_date, daily_revenue) вывести накопительную выручку с начала каждого календарного месяца (metric month-to-date).
Показать решение
SELECT
order_date,
daily_revenue,
SUM(daily_revenue) OVER (
PARTITION BY strftime('%Y-%m', order_date)
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS mtd_revenue
FROM daily_sales
ORDER BY order_date;
Внутри каждого месяца — свой нарастающий итог, на 1-е число следующего счётчик обнуляется. Без оконной функции пришлось бы городить самосоединение с календарём — работает заметно медленнее.
Задача 5 (уровень Senior) — клиенты с тремя уменьшениями чека подряд
Условие: найти пользователей, у которых сумма заказа падает три раза подряд. Простой маркер снижения платёжеспособности.
Это классический паттерн «gaps and islands» — один из самых хитрых в SQL. Идея: каждый заказ помечаем флагом «меньше предыдущего?», строим id группы подряд идущих уменьшений, считаем длину каждой такой серии.
Показать решение с пояснением каждого шага
WITH diffs AS (
-- Шаг 1: для каждого заказа считаем разницу с предыдущим
SELECT
user_id,
order_date,
amount,
amount - LAG(amount) OVER (
PARTITION BY user_id
ORDER BY order_date
) AS diff
FROM orders
),
flags AS (
-- Шаг 2: флаг «уменьшение» = 1, иначе 0.
-- Первая строка пользователя (LAG=NULL → diff=NULL) в счёт не идёт.
SELECT
user_id,
order_date,
amount,
CASE WHEN diff < 0 THEN 1 ELSE 0 END AS is_decrease
FROM diffs
WHERE diff IS NOT NULL
),
groups AS (
-- Шаг 3: идентификатор группы подряд идущих уменьшений.
-- Накопительная сумма НЕ-уменьшений меняется только когда цепочка прерывается,
-- значит всем строкам одной цепочки присвоится один и тот же grp.
SELECT
user_id,
order_date,
is_decrease,
SUM(CASE WHEN is_decrease = 0 THEN 1 ELSE 0 END) OVER (
PARTITION BY user_id
ORDER BY order_date
) AS grp
FROM flags
),
runs AS (
-- Шаг 4: длина каждой цепочки уменьшений
SELECT
user_id,
grp,
COUNT(*) AS decrease_streak
FROM groups
WHERE is_decrease = 1
GROUP BY user_id, grp
)
SELECT DISTINCT user_id
FROM runs
WHERE decrease_streak >= 3;
Если на собеседовании удалось объяснить идею и написать такой запрос — оконная часть собеседования закрыта. Разборы других сложных кейсов с аргументацией — в разделе Продуктовые кейсы.
Частые ошибки на собесах
Четыре самые частые ошибки. Если их помнить, оконная часть собеса пройдёт без сюрпризов.
WHERE ROW_NUMBER() OVER (...) = 1 не работает, потому что оконные функции выполняются ПОСЛЕ WHERE. Нужно обернуть в CTE или подзапрос и фильтровать во внешнем запросе.ORDER BY результат LAG и LEAD непредсказуем — СУБД сама выбирает порядок и он может меняться между запусками. Всегда указывайте явный ORDER BY.LAST_VALUE и аналогичные функции по умолчанию работают до текущей строки, а не до конца окна. Чтобы получить реально «последнюю строку в группе», задавайте ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.PARTITION BY делит данные на окна, но не сжимает строки. GROUP BY сжимает. Если в запросе ожидалась одна строка на пользователя, а получилось много — скорее всего, перепутаны эти конструкции.Порядок выполнения SQL с оконной функцией
Эту шпаргалку стоит держать в голове — сразу понятно, где оконную функцию можно применять, а где нет.
1. FROM / JOIN — собрали строки
2. WHERE — отфильтровали
3. GROUP BY / HAVING — сжали в группы (если есть)
4. WINDOW FUNCTIONS — посчитали окна на уже сжатом наборе
5. SELECT — выбрали поля
6. ORDER BY / LIMIT — финальный порядок
Вот почему WHERE ROW_NUMBER() = 1 не работает: WHERE — шаг 2, оконная функция — шаг 4. Когда WHERE отрабатывает, ROW_NUMBER ещё не посчитан. А ORDER BY ROW_NUMBER() OVER (...) работает нормально — сортировка идёт на шаге 6, позже оконных функций.
Что где поддерживается
Не все базы работают с оконными функциями одинаково. Если приходится переключаться между СУБД — держи таблицу под рукой.
| СУБД | Оконные функции | Поддержка frame |
|---|---|---|
| PostgreSQL 11+ | Все | Полная, включая RANGE с INTERVAL |
| ClickHouse | Все основные | Частичная |
| MySQL 8.0+ | Все | Полная |
| MySQL 5.x | Нет | — |
| SQLite 3.25+ | Все | Только ROWS |
| BigQuery | Все | Полная, есть QUALIFY |
| Snowflake | Все | Полная, есть QUALIFY |
Отдельно про QUALIFY в BigQuery и Snowflake. Работает как WHERE, только после оконных функций. Без него пришлось бы писать CTE, с ним — один SELECT:
-- BigQuery / Snowflake: один запрос без обёртки
SELECT
user_id,
order_date,
amount,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY order_date
) AS rn
FROM orders
QUALIFY rn = 1;
В PostgreSQL, MySQL и SQLite QUALIFY нет, там нужна обёртка через CTE или подзапрос.
Шпаргалка
| Нужно | Функция / конструкция |
|---|---|
| Нумерация строк в группе | ROW_NUMBER() |
| Ранжирование с пропусками | RANK() |
| Ранжирование без пропусков | DENSE_RANK() |
| Значение предыдущей строки | LAG(col) |
| Значение следующей строки | LEAD(col) |
| Накопительная сумма | SUM(col) OVER (ORDER BY ...) |
| Скользящее среднее | AVG(col) OVER (ORDER BY ... ROWS BETWEEN N PRECEDING AND CURRENT ROW) |
| Первое / последнее значение в окне | FIRST_VALUE / LAST_VALUE |
| Разбиение на квартили | NTILE(4) |
| Процентильный ранг | PERCENT_RANK() |
Как закрепить
Только теория не даёт навыка — нужно решать. План на неделю, после которого оконные функции становятся рабочим инструментом.
PARTITION BY режет на окна, ORDER BY задаёт порядок внутри, frame clause — границы. WHERE считается ДО оконной функции, QUALIFY или CTE — ПОСЛЕ. Услышал «для каждого пользователя/дня/группы посчитать агрегат и не потерять строки» — это про оконную функцию.Связанные материалы
- SQL-тренажёр — 300 задач с проверкой кода в браузере, первые 5 бесплатно
- Вопросы с собеседований — 2000+ карточек по SQL, Python, статистике, A/B-тестам
- Продуктовые кейсы — 225 кейсов с разбором
- AI-собеседование — разбор решений с честной обратной связью
- Конспекты по SQL и статистике — 210 модулей теории
- Продуктовые метрики — 265 метрик с формулами и SQL-запросами
- Как стать аналитиком данных в 2026 — полный гайд по входу в профессию
Закрой гайд, открой SQL-тренажёр, реши первую задачу на ROW_NUMBER. Через 20 минут оконные перестанут казаться сложными.